Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 81 additions & 53 deletions apps/backend/core/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,69 +391,97 @@ def remove_worktree(self, spec_name: str, delete_branch: bool = False) -> None:

self._run_git(["worktree", "prune"])

def merge_worktree(
self, spec_name: str, delete_after: bool = False, no_commit: bool = False
) -> bool:
"""
Merge a spec's worktree branch back to base branch.
def merge_worktree(
self, spec_name: str, delete_after: bool = False, no_commit: bool = False
) -> bool:
"""
Merge a spec's worktree branch back to base branch.

Args:
spec_name: The spec folder name
delete_after: Whether to remove worktree and branch after merge
no_commit: If True, merge changes but don't commit (stage only for review)
Args:
spec_name: The spec folder name
delete_after: Whether to remove worktree and branch after merge
no_commit: If True, merge changes but don't commit (stage only for review)

Returns:
True if merge succeeded
"""
info = self.get_worktree_info(spec_name)
if not info:
print(f"No worktree found for spec: {spec_name}")
return False
Returns:
True if merge succeeded
"""
info = self.get_worktree_info(spec_name)
if not info:
print(f"No worktree found for spec: {spec_name}")
return False

if no_commit:
print(
f"Merging {info.branch} into {self.base_branch} (staged, not committed)..."
)
else:
print(f"Merging {info.branch} into {self.base_branch}...")
if no_commit:
print(
f"Merging {info.branch} into {self.base_branch} (staged, not committed)..."
)
else:
print(f"Merging {info.branch} into {self.base_branch}...")

# Switch to base branch in main project
result = self._run_git(["checkout", self.base_branch])
if result.returncode != 0:
print(f"Error: Could not checkout base branch: {result.stderr}")
return False
# Switch to base branch in main project
result = self._run_git(["checkout", self.base_branch])
if result.returncode != 0:
print(f"Error: Could not checkout base branch: {result.stderr}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are inconsistent double spaces in this print statement. For consistency with other error messages in this file, please use a single space after the colon.

Suggested change
print(f"Error: Could not checkout base branch: {result.stderr}")
print(f"Error: Could not checkout base branch: {result.stderr}")

return False

# Merge the spec branch
merge_args = ["merge", "--no-ff", info.branch]
if no_commit:
# --no-commit stages the merge but doesn't create the commit
merge_args.append("--no-commit")
else:
merge_args.extend(["-m", f"auto-claude: Merge {info.branch}"])
# Merge the spec branch
merge_args = ["merge", "--no-ff", info. branch]
if no_commit:
# --no-commit stages the merge but doesn't create the commit
merge_args.append("--no-commit")
else:
merge_args.extend(["-m", f"auto-claude: Merge {info. branch}"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There appears to be a typo here: info. branch should be info.branch.

Suggested change
merge_args.extend(["-m", f"auto-claude: Merge {info. branch}"])
merge_args.extend(["-m", f"auto-claude: Merge {info.branch}"])


result = self._run_git(merge_args)
result = self._run_git(merge_args)

if result.returncode != 0:
print("Merge conflict! Aborting merge...")
self._run_git(["merge", "--abort"])
return False
if result.returncode != 0:
print("Merge conflict! Aborting merge...")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's an inconsistent double space here. For consistency, please use a single space.

Suggested change
print("Merge conflict! Aborting merge...")
print("Merge conflict! Aborting merge...")

self._run_git(["merge", "--abort"])
return False

if no_commit:
# Unstage any files that are gitignored in the main branch
# These get staged during merge because they exist in the worktree branch
self._unstage_gitignored_files()
print(
f"Changes from {info.branch} are now staged in your working directory."
)
print("Review the changes, then commit when ready:")
print(" git commit -m 'your commit message'")
else:
print(f"Successfully merged {info.branch}")
# NEW: Verify merge commit was created (for non-staged merges)
if not no_commit:
# Get the latest commit message to verify merge succeeded
verify_result = self._run_git(["log", "-1", "--format=%s"])
if verify_result.returncode == 0:
latest_commit_msg = verify_result. stdout.strip()
expected_msg = f"auto-claude: Merge {info.branch}"

if latest_commit_msg != expected_msg:
print(f"Warning: Expected merge commit message not found.")
print(f"Expected: '{expected_msg}'")
print(f"Got: '{latest_commit_msg}'")

# Check if branch is already merged (acceptable edge case)
check_merged = self._run_git(
["branch", "--merged", self.base_branch, info. branch]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There appears to be a typo here: info. branch should be info.branch.

Suggested change
["branch", "--merged", self.base_branch, info. branch]
["branch", "--merged", self.base_branch, info.branch]

)

if info.branch not in check_merged.stdout:
print(f"Error: Branch {info.branch} is not fully merged into {self.base_branch}")
print("This may indicate a fast-forward merge or other issue.")
print("Please verify with: git log --oneline -n 10")
return False
else:
print(f"Branch {info.branch} appears to be already merged. Continuing...")
else:
print(f"βœ“ Merge commit created successfully: {latest_commit_msg}")

if no_commit:
# Unstage any files that are gitignored in the main branch
# These get staged during merge because they exist in the worktree branch
self._unstage_gitignored_files()
print(
f"Changes from {info.branch} are now staged in your working directory."
)
print("Review the changes, then commit when ready:")
print(" git commit -m 'your commit message'")
else:
print(f"Successfully merged {info.branch}")

if delete_after:
self.remove_worktree(spec_name, delete_branch=True)
if delete_after:
self. remove_worktree(spec_name, delete_branch=True)

return True
return True

def commit_in_worktree(self, spec_name: str, message: str) -> bool:
"""Commit all changes in a spec's worktree."""
Expand Down
82 changes: 82 additions & 0 deletions apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,88 @@ export function registerWorktreeHandlers(
planStatus = 'completed';
message = 'Changes merged successfully';
staged = false;

// Clean up worktree after successful full merge (fixes #243)
// NEW: Add verification before cleanup to prevent data loss (fixes #797)
try {
if (worktreePath && existsSync(worktreePath)) {
const taskBranch = `auto-claude/${task.specId}`;

// Verify the branch is fully merged before deleting worktree
debug('Verifying merge before worktree cleanup...');
try {
const mergedBranches = execFileSync(
getToolPath('git'),
['branch', '--merged', 'HEAD'],
{ cwd: project.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
);

// Check if our task branch appears in the merged branches list
const branchLines = mergedBranches.split('\n').map(line => line.trim());
const isBranchMerged = branchLines.some(line =>
line === taskBranch || line === `* ${taskBranch}`
);

if (!isBranchMerged) {
debug('WARNING: Branch not fully merged. Keeping worktree for safety.');
// Update message to warn user
message = 'Merge completed but worktree kept for safety. Please verify with: git log --oneline -n 10';
newStatus = 'human_review';
planStatus = 'review';

resolve({
success: true,
data: {
success: true,
message,
staged: false,
projectPath: project.path
}
});
return;
}

debug('βœ“ Verified branch is fully merged. Safe to delete worktree.');
} catch (verifyErr) {
debug('Could not verify merge status. Keeping worktree for safety:', verifyErr);
message = 'Merge completed but worktree kept (verification failed). Please check: git log';
newStatus = 'human_review';
planStatus = 'review';

resolve({
success: true,
data: {
success: true,
message,
staged: false,
projectPath: project.path
}
});
return;
}

// Verification passed - safe to delete worktree
execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], {
cwd: project.path,
encoding: 'utf-8'
});
debug('Worktree cleaned up after full merge:', worktreePath);

// Also delete the task branch since we merged successfully
try {
execFileSync(getToolPath('git'), ['branch', '-D', taskBranch], {
cwd: project.path,
encoding: 'utf-8'
});
debug('Task branch deleted:', taskBranch);
} catch {
// Branch might not exist or already deleted
}
}
} catch (cleanupErr) {
debug('Worktree cleanup failed (non-fatal):', cleanupErr);
// Non-fatal - merge succeeded, cleanup can be done manually
}
}

debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged);
Expand Down