feat(worktree): merge and rebase actions in the worktree context menu - #1309
feat(worktree): merge and rebase actions in the worktree context menu#1309pedramamini wants to merge 1 commit into
Conversation
Worktree sub-agents could create a PR or be removed, but there was no way to land their work without dropping to a terminal. Adds two context-menu entries on worktree agents - "Merge Branch Into..." and "Rebase Branch Onto..." - both opening a confirmation modal with a branch picker that defaults to the repo's default branch. Backend adds two IPC handlers, both SSH-aware: - git:mergeBranch runs in whichever worktree has the target branch checked out, since git will not check the same branch out twice. It refuses when that checkout is dirty, and on conflict it runs merge --abort and returns the conflicting paths rather than leaving the user's main checkout stuck half-merged from one button press. - git:rebaseBranch runs in place in the worktree; the base branch only needs to exist as a ref. Same clean-tree requirement and same abort-on-conflict behavior. Because neither operation runs against a dirty tree, the modal detects uncommitted changes in the worktree and offers to commit them first with an editable message, reusing the existing git:commitAll handler. Branch names are checked for a leading dash before reaching git. execGit already uses execFile (no shell), so this just stops a name being parsed as a flag rather than a ref.
📝 WalkthroughWalkthroughChangesWorktree merge and rebase workflows now include Git IPC handlers, preload and renderer service APIs, modal-store state, context-menu actions, and a new Worktree Git operations
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant WorktreeMergeModal
participant gitService
participant GitIPC
participant GitWorktree
User->>WorktreeMergeModal: Select target and run merge/rebase
WorktreeMergeModal->>gitService: Call mergeBranch or rebaseBranch
gitService->>GitIPC: Invoke IPC channel
GitIPC->>GitWorktree: Execute Git operation
GitWorktree-->>GitIPC: Return operation result
GitIPC-->>gitService: Return success, conflict, or error payload
gitService-->>WorktreeMergeModal: Update result banner
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryAdds worktree merge and rebase actions across the renderer, preload bridge, and main-process Git handlers.
Confidence Score: 2/5The SSH path handling, merge cleanup, and target filtering defects need to be fixed before merging. SSH operations lose the remote working directory, failed merges can leave the target checkout in unfinished merge state, and the modal offers remote-only refs that the merge handler cannot use. Files Needing Attention: src/renderer/components/WorktreeMergeModal.tsx, src/renderer/services/git.ts, src/main/ipc/handlers/git.ts Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant Modal as WorktreeMergeModal
participant Service as gitService
participant Preload
participant Handler as Git IPC handler
participant Git
User->>Modal: Choose merge or rebase
Modal->>Service: Load branches and status
Service->>Preload: Invoke Git API
Preload->>Handler: IPC request
Handler->>Git: Run in selected worktree
Git-->>Handler: Success, error, or conflicts
Handler-->>Modal: Structured result
Modal-->>User: Display outcome
Reviews (1): Last reviewed commit: "feat(worktree): merge and rebase actions..." | Re-trigger Greptile |
| gitService.getBranches(session.cwd, sshRemoteId), | ||
| window.maestro.git.getDefaultBranch(session.cwd), | ||
| gitService.getStatus(session.cwd, sshRemoteId), |
There was a problem hiding this comment.
SSH working directory is dropped
When an SSH worktree uses a remote repository path different from session.cwd, these calls omit session.remoteCwd, so the handlers interpret the local path on the remote host. Branch and status loading then fail, and commit, merge, or rebase cannot operate on the intended repository. Default-branch discovery also runs locally and can preselect a branch from the wrong repository.
Context Used: CLAUDE.md (source)
Knowledge Base Used: IPC Layer
| // Roll back so the target checkout is left exactly as we found it. | ||
| await execGit(['merge', '--abort'], targetCwd, sshRemote, targetRemoteCwd); | ||
| return { success: false, mergedIn: targetCwd, conflicts }; | ||
| } | ||
| return { | ||
| success: false, |
There was a problem hiding this comment.
Non-conflict merge state persists
When git merge creates merge state but exits unsuccessfully without unmerged paths, such as when a commit hook rejects the merge commit, listConflictedPaths returns an empty array and this branch skips git merge --abort. The target checkout remains in an unfinished merge even though the UI reports an ordinary error, blocking later Git operations until the user repairs it manually.
Knowledge Base Used: IPC Layer
| const selectable = allBranches.filter((b) => b !== sourceBranch); | ||
| const defaultBranch = defaultBranchResult.branch || ''; | ||
| const sorted = [...selectable].sort((a, b) => { | ||
| if (a === defaultBranch && b !== defaultBranch) return -1; | ||
| if (a !== defaultBranch && b === defaultBranch) return 1; | ||
| return a.localeCompare(b); | ||
| }); | ||
| setBranches(sorted); | ||
| setTargetBranch(sorted[0] || ''); |
There was a problem hiding this comment.
Invalid merge targets remain selectable
getBranches includes remote-tracking refs and this filter removes only the source branch, while mergeBranch accepts targets only when their local branch is checked out in a worktree. Selecting a remote-only ref therefore reaches the handler but deterministically fails with "not checked out in any worktree" even though the modal presented it as a valid merge target.
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/__tests__/main/ipc/handlers/git.test.ts`:
- Around line 251-261: The mockGit implementation at
src/__tests__/main/ipc/handlers/git.test.ts#L251-L261 must handle merge --abort
explicitly with exit code 0 before generic prefix matching; update the rebase
mock at src/__tests__/main/ipc/handlers/git.test.ts#L397-L405 likewise to return
exit code 0 for rebase --abort before its generic command match, preserving the
existing override behavior for other commands.
In `@src/main/ipc/handlers/git.ts`:
- Around line 401-419: Update the merge failure handling after execGit in the
merge flow to always call execGit with ['merge', '--abort'] whenever
mergeResult.exitCode is nonzero, before branching on conflicts. Preserve the
existing conflict response and non-conflict error details, while ensuring
cleanup is attempted even when listConflictedPaths returns no paths.
In `@src/renderer/components/WorktreeMergeModal.tsx`:
- Around line 52-55: Update WorktreeMergeModal’s dismissal handlers and
useModalLayer configuration so backdrop clicks, the close button, Cancel, and
Escape cannot invoke onClose while isRunning is true. Preserve normal dismissal
when idle, and ensure handleRun’s in-flight operation keeps the modal mounted
until completion.
- Around line 57-63: Update WorktreeMergeModal to track whether branch loading
has completed independently of branches.length and branchLoadError. Set the
loaded flag after fetching selectable branches, including when the result is
empty, then use it in the dropdown state so an empty result shows a clear “no
branches available” message instead of “Loading branches…”. Preserve the
disabled action behavior when no target branch exists.
- Around line 84-117: Update the default-branch lookup used by
WorktreeMergeModal so it accepts and propagates sshRemoteId through
window.maestro.git.getDefaultBranch and the corresponding git:getDefaultBranch
IPC handler, using the remote-aware execution path for SSH sessions. Preserve
local behavior when no SSH remote is provided, and ensure the Promise.all
branch-loading flow receives the correct default branch without triggering
branchLoadError for valid remote worktrees.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1be5bea7-6812-4bef-a226-fc5e3955dbbe
📒 Files selected for processing (17)
src/__tests__/main/ipc/handlers/git.test.tssrc/__tests__/renderer/components/WorktreeMergeModal.test.tsxsrc/__tests__/setup.tssrc/main/ipc/handlers/git.tssrc/main/preload/git.tssrc/renderer/App.tsxsrc/renderer/components/AppModals/AppModals.tsxsrc/renderer/components/AppModals/AppWorktreeModals.tsxsrc/renderer/components/SessionList/SessionContextMenu.tsxsrc/renderer/components/SessionList/SessionList.tsxsrc/renderer/components/WorktreeMergeModal.tsxsrc/renderer/constants/modalPriorities.tssrc/renderer/global.d.tssrc/renderer/hooks/props/useSessionListProps.tssrc/renderer/hooks/worktree/useWorktreeHandlers.tssrc/renderer/services/git.tssrc/renderer/stores/modalStore.ts
| const mockGit = (overrides: Record<string, { stdout?: string; exitCode?: number }>) => { | ||
| vi.mocked(execFile.execFileNoThrow).mockImplementation(async (_cmd, args) => { | ||
| const key = (args as string[]).join(' '); | ||
| const match = Object.keys(overrides).find((k) => key.startsWith(k)); | ||
| const o = match ? overrides[match] : {}; | ||
| return { | ||
| stdout: o.stdout ?? '', | ||
| stderr: '', | ||
| exitCode: o.exitCode ?? 0, | ||
| }; | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the abort mocks succeed.
The prefix matching at Line 254 also matches merge --abort, and the rebase mock also fails rebase --abort. Add explicit successful abort cases before the generic command match so these tests validate cleanup correctly.
src/__tests__/main/ipc/handlers/git.test.ts#L251-L261: return exit code0formerge --abort.src/__tests__/main/ipc/handlers/git.test.ts#L397-L405: return exit code0forrebase --abort.
📍 Affects 1 file
src/__tests__/main/ipc/handlers/git.test.ts#L251-L261(this comment)src/__tests__/main/ipc/handlers/git.test.ts#L397-L405
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/__tests__/main/ipc/handlers/git.test.ts` around lines 251 - 261, The
mockGit implementation at src/__tests__/main/ipc/handlers/git.test.ts#L251-L261
must handle merge --abort explicitly with exit code 0 before generic prefix
matching; update the rebase mock at
src/__tests__/main/ipc/handlers/git.test.ts#L397-L405 likewise to return exit
code 0 for rebase --abort before its generic command match, preserving the
existing override behavior for other commands.
| const mergeResult = await execGit( | ||
| ['merge', sourceBranch], | ||
| targetCwd, | ||
| sshRemote, | ||
| targetRemoteCwd | ||
| ); | ||
| if (mergeResult.exitCode !== 0) { | ||
| const conflicts = await listConflictedPaths(targetCwd, sshRemote, targetRemoteCwd); | ||
| if (conflicts.length > 0) { | ||
| // Roll back so the target checkout is left exactly as we found it. | ||
| await execGit(['merge', '--abort'], targetCwd, sshRemote, targetRemoteCwd); | ||
| return { success: false, mergedIn: targetCwd, conflicts }; | ||
| } | ||
| return { | ||
| success: false, | ||
| mergedIn: targetCwd, | ||
| error: mergeResult.stderr?.trim() || mergeResult.stdout?.trim() || 'git merge failed', | ||
| }; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Merge failures without conflicts skip the merge --abort cleanup, unlike the rebase handler.
When mergeResult.exitCode !== 0 but listConflictedPaths returns empty (e.g. a commit-msg/pre-commit hook rejects the auto-generated merge commit after content auto-merged cleanly), the code returns an error without calling git merge --abort. This leaves MERGE_HEAD set and the merge staged in the target worktree — exactly the "half-merged state from a single button press" the surrounding comment says this design avoids. The rebase handler below (lines 473-486) already handles this correctly by calling --abort unconditionally on any failure, since abort is a documented no-op-with-error when nothing is in progress. Apply the same pattern here.
🐛 Proposed fix: always attempt abort on merge failure
if (mergeResult.exitCode !== 0) {
const conflicts = await listConflictedPaths(targetCwd, sshRemote, targetRemoteCwd);
+ // Always attempt cleanup: --abort is a no-op-with-error when no merge
+ // is in progress, but essential when a hook rejects the
+ // auto-generated merge commit, which leaves MERGE_HEAD set with
+ // no conflicted files to detect.
+ await execGit(['merge', '--abort'], targetCwd, sshRemote, targetRemoteCwd);
if (conflicts.length > 0) {
- // Roll back so the target checkout is left exactly as we found it.
- await execGit(['merge', '--abort'], targetCwd, sshRemote, targetRemoteCwd);
return { success: false, mergedIn: targetCwd, conflicts };
}
return {
success: false,
mergedIn: targetCwd,
error: mergeResult.stderr?.trim() || mergeResult.stdout?.trim() || 'git merge failed',
};
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const mergeResult = await execGit( | |
| ['merge', sourceBranch], | |
| targetCwd, | |
| sshRemote, | |
| targetRemoteCwd | |
| ); | |
| if (mergeResult.exitCode !== 0) { | |
| const conflicts = await listConflictedPaths(targetCwd, sshRemote, targetRemoteCwd); | |
| if (conflicts.length > 0) { | |
| // Roll back so the target checkout is left exactly as we found it. | |
| await execGit(['merge', '--abort'], targetCwd, sshRemote, targetRemoteCwd); | |
| return { success: false, mergedIn: targetCwd, conflicts }; | |
| } | |
| return { | |
| success: false, | |
| mergedIn: targetCwd, | |
| error: mergeResult.stderr?.trim() || mergeResult.stdout?.trim() || 'git merge failed', | |
| }; | |
| } | |
| const mergeResult = await execGit( | |
| ['merge', sourceBranch], | |
| targetCwd, | |
| sshRemote, | |
| targetRemoteCwd | |
| ); | |
| if (mergeResult.exitCode !== 0) { | |
| const conflicts = await listConflictedPaths(targetCwd, sshRemote, targetRemoteCwd); | |
| // Always attempt cleanup: --abort is a no-op-with-error when no merge | |
| // is in progress, but essential when a hook rejects the | |
| // auto-generated merge commit, which leaves MERGE_HEAD set with | |
| // no conflicted files to detect. | |
| await execGit(['merge', '--abort'], targetCwd, sshRemote, targetRemoteCwd); | |
| if (conflicts.length > 0) { | |
| return { success: false, mergedIn: targetCwd, conflicts }; | |
| } | |
| return { | |
| success: false, | |
| mergedIn: targetCwd, | |
| error: mergeResult.stderr?.trim() || mergeResult.stdout?.trim() || 'git merge failed', | |
| }; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/ipc/handlers/git.ts` around lines 401 - 419, Update the merge
failure handling after execGit in the merge flow to always call execGit with
['merge', '--abort'] whenever mergeResult.exitCode is nonzero, before branching
on conflicts. Preserve the existing conflict response and non-conflict error
details, while ensuring cleanup is attempted even when listConflictedPaths
returns no paths.
| useModalLayer(MODAL_PRIORITIES.WORKTREE_MERGE, undefined, () => onCloseRef.current(), { | ||
| focusTrap: 'lenient', | ||
| enabled: isOpen, | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Modal can be dismissed mid-operation.
The backdrop, close (X), Cancel button, and Escape (via useModalLayer) all call onClose unconditionally, even while isRunning is true. A user can close the modal while commitAll/mergeBranch/rebaseBranch is still in flight; the async .then/finally callbacks in handleRun will still fire setResult/setIsRunning after the component has unmounted.
♻️ Proposed fix: disable dismissal while running
- <div className="absolute inset-0 bg-black/60" onClick={onClose} />
+ <div className="absolute inset-0 bg-black/60" onClick={isRunning ? undefined : onClose} /> <button
type="button"
onClick={onClose}
+ disabled={isRunning}
className="px-3 py-1.5 rounded border hover:bg-white/5 transition-colors outline-none text-xs"Also applies to: 197-197, 218-220, 388-395
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/components/WorktreeMergeModal.tsx` around lines 52 - 55, Update
WorktreeMergeModal’s dismissal handlers and useModalLayer configuration so
backdrop clicks, the close button, Cancel, and Escape cannot invoke onClose
while isRunning is true. Preserve normal dismissal when idle, and ensure
handleRun’s in-flight operation keeps the modal mounted until completion.
| const [branches, setBranches] = useState<string[]>([]); | ||
| const [targetBranch, setTargetBranch] = useState(''); | ||
| const [branchLoadError, setBranchLoadError] = useState(false); | ||
| const [dirtyFileCount, setDirtyFileCount] = useState(0); | ||
| const [commitMessage, setCommitMessage] = useState(''); | ||
| const [isRunning, setIsRunning] = useState(false); | ||
| const [result, setResult] = useState<Result | null>(null); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
"Loading branches…" never resolves when there are no selectable branches.
If a repo has only one branch (the worktree's own), selectable ends up empty, branches stays [], and branchLoadError is false — so the dropdown shows "Loading branches…" indefinitely with the action button permanently disabled, with no explanation that there's simply nothing to merge/rebase onto.
♻️ Proposed fix: track a loaded flag to distinguish loading from an empty result
const [branches, setBranches] = useState<string[]>([]);
+ const [branchesLoaded, setBranchesLoaded] = useState(false); setBranches(sorted);
setTargetBranch(sorted[0] || '');
setDirtyFileCount(status.files.length);
+ setBranchesLoaded(true);- {branches.length === 0 && !branchLoadError && (
+ {branches.length === 0 && !branchLoadError && !branchesLoaded && (
<option value="">Loading branches…</option>
)}
+ {branches.length === 0 && !branchLoadError && branchesLoaded && (
+ <option value="">No other branches available</option>
+ )}Also applies to: 93-106, 266-268
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/components/WorktreeMergeModal.tsx` around lines 57 - 63, Update
WorktreeMergeModal to track whether branch loading has completed independently
of branches.length and branchLoadError. Set the loaded flag after fetching
selectable branches, including when the result is empty, then use it in the
dropdown state so an empty result shows a clear “no branches available” message
instead of “Loading branches…”. Preserve the disabled action behavior when no
target branch exists.
| useEffect(() => { | ||
| if (!isOpen) return; | ||
|
|
||
| let cancelled = false; | ||
| Promise.all([ | ||
| gitService.getBranches(session.cwd, sshRemoteId), | ||
| window.maestro.git.getDefaultBranch(session.cwd), | ||
| gitService.getStatus(session.cwd, sshRemoteId), | ||
| ]) | ||
| .then(([allBranches, defaultBranchResult, status]) => { | ||
| if (cancelled) return; | ||
| // The worktree's own branch is never a valid merge target or rebase | ||
| // base - merging a branch into itself is a no-op at best. | ||
| const selectable = allBranches.filter((b) => b !== sourceBranch); | ||
| const defaultBranch = defaultBranchResult.branch || ''; | ||
| const sorted = [...selectable].sort((a, b) => { | ||
| if (a === defaultBranch && b !== defaultBranch) return -1; | ||
| if (a !== defaultBranch && b === defaultBranch) return 1; | ||
| return a.localeCompare(b); | ||
| }); | ||
| setBranches(sorted); | ||
| setTargetBranch(sorted[0] || ''); | ||
| setDirtyFileCount(status.files.length); | ||
| }) | ||
| .catch((err) => { | ||
| if (cancelled) return; | ||
| captureException(err, { extra: { cwd: session.cwd, sshRemoteId, mode } }); | ||
| setBranchLoadError(true); | ||
| }); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [isOpen, session.cwd, sshRemoteId, sourceBranch, mode]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
getDefaultBranch call ignores sshRemoteId, breaking SSH-remote sessions.
window.maestro.git.getDefaultBranch(session.cwd) is called without sshRemoteId, while the other two calls in the same Promise.all correctly pass it. The git:getDefaultBranch handler in src/main/ipc/handlers/git.ts has no SSH parameter at all and always shells out locally, so for an SSH-remote worktree this call runs git remote show origin (and the main/master fallbacks) against a path that only exists on the remote host. That will fail and land in the .catch, setting branchLoadError and disabling the entire branch picker — for a feature whose stated purpose is SSH-aware merge/rebase.
🐛 Proposed mitigation (local fix; full fix needs `git:getDefaultBranch` to accept `sshRemoteId`)
let cancelled = false;
Promise.all([
gitService.getBranches(session.cwd, sshRemoteId),
- window.maestro.git.getDefaultBranch(session.cwd),
+ // git:getDefaultBranch has no SSH-remote support yet; skip it for SSH
+ // sessions instead of running it against a non-existent local path.
+ sshRemoteId
+ ? Promise.resolve({ branch: '' })
+ : window.maestro.git.getDefaultBranch(session.cwd),
gitService.getStatus(session.cwd, sshRemoteId),
])📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| if (!isOpen) return; | |
| let cancelled = false; | |
| Promise.all([ | |
| gitService.getBranches(session.cwd, sshRemoteId), | |
| window.maestro.git.getDefaultBranch(session.cwd), | |
| gitService.getStatus(session.cwd, sshRemoteId), | |
| ]) | |
| .then(([allBranches, defaultBranchResult, status]) => { | |
| if (cancelled) return; | |
| // The worktree's own branch is never a valid merge target or rebase | |
| // base - merging a branch into itself is a no-op at best. | |
| const selectable = allBranches.filter((b) => b !== sourceBranch); | |
| const defaultBranch = defaultBranchResult.branch || ''; | |
| const sorted = [...selectable].sort((a, b) => { | |
| if (a === defaultBranch && b !== defaultBranch) return -1; | |
| if (a !== defaultBranch && b === defaultBranch) return 1; | |
| return a.localeCompare(b); | |
| }); | |
| setBranches(sorted); | |
| setTargetBranch(sorted[0] || ''); | |
| setDirtyFileCount(status.files.length); | |
| }) | |
| .catch((err) => { | |
| if (cancelled) return; | |
| captureException(err, { extra: { cwd: session.cwd, sshRemoteId, mode } }); | |
| setBranchLoadError(true); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [isOpen, session.cwd, sshRemoteId, sourceBranch, mode]); | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| let cancelled = false; | |
| Promise.all([ | |
| gitService.getBranches(session.cwd, sshRemoteId), | |
| // git:getDefaultBranch has no SSH-remote support yet; skip it for SSH | |
| // sessions instead of running it against a non-existent local path. | |
| sshRemoteId | |
| ? Promise.resolve({ branch: '' }) | |
| : window.maestro.git.getDefaultBranch(session.cwd), | |
| gitService.getStatus(session.cwd, sshRemoteId), | |
| ]) | |
| .then(([allBranches, defaultBranchResult, status]) => { | |
| if (cancelled) return; | |
| // The worktree's own branch is never a valid merge target or rebase | |
| // base - merging a branch into itself is a no-op at best. | |
| const selectable = allBranches.filter((b) => b !== sourceBranch); | |
| const defaultBranch = defaultBranchResult.branch || ''; | |
| const sorted = [...selectable].sort((a, b) => { | |
| if (a === defaultBranch && b !== defaultBranch) return -1; | |
| if (a !== defaultBranch && b === defaultBranch) return 1; | |
| return a.localeCompare(b); | |
| }); | |
| setBranches(sorted); | |
| setTargetBranch(sorted[0] || ''); | |
| setDirtyFileCount(status.files.length); | |
| }) | |
| .catch((err) => { | |
| if (cancelled) return; | |
| captureException(err, { extra: { cwd: session.cwd, sshRemoteId, mode } }); | |
| setBranchLoadError(true); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [isOpen, session.cwd, sshRemoteId, sourceBranch, mode]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/renderer/components/WorktreeMergeModal.tsx` around lines 84 - 117, Update
the default-branch lookup used by WorktreeMergeModal so it accepts and
propagates sshRemoteId through window.maestro.git.getDefaultBranch and the
corresponding git:getDefaultBranch IPC handler, using the remote-aware execution
path for SSH sessions. Preserve local behavior when no SSH remote is provided,
and ensure the Promise.all branch-loading flow receives the correct default
branch without triggering branchLoadError for valid remote worktrees.
Closes #263
What
Worktree sub-agents could already create a PR or be removed, but there was no way to land their work without dropping to a terminal. This adds two entries to the right-click menu on a worktree agent:
Both open a confirmation modal with a branch picker that defaults to the repo's default branch. This follows the direction in the issue thread: "Adding these as options on the right-click menu for worktrees does sound like a nice idea."
How
Two new SSH-aware IPC handlers in
src/main/ipc/handlers/git.ts:git:mergeBranchruns in whichever worktree currently has the target branch checked out, located viagit worktree list --porcelain. That indirection is necessary: git will not check the same branch out twice, so the merge cannot just happen inside the source worktree. It refuses to run when that checkout has uncommitted changes, and on conflict it runsgit merge --abortand returns the conflicting paths. A single button press should never leave someone's main checkout stuck half-merged.git:rebaseBranchruns in place in the worktree - the base branch only needs to exist as a ref, no checkout required. Same clean-tree requirement, same abort-on-conflict behavior.Because neither operation runs against a dirty tree,
WorktreeMergeModaldetects uncommitted changes in the worktree and offers to commit them first with an editable message, reusing the existinggit:commitAllhandler. That covers the issue's ask for "a pop-up window to allow me to confirm the message content".Branch names get a leading-dash check before reaching git.
execGitalready usesexecFile(no shell), so this only stops a name being parsed as a flag rather than as a ref.Not in this PR
Two parts of the original request are deliberately left out, and I'd rather confirm the shape before building them:
Choosing the base branch when creating a worktree already works (
CreateWorktreeModalhas a Base Branch picker); this PR adds the matching choice on the merge/rebase side.Testing
src/__tests__/main/ipc/handlers/git.test.tscovering: merging in the target's worktree, already-up-to-date, refusing a dirty target, abort-and-report on conflict, target branch not checked out anywhere, self-merge, and flag-like branch names - plus the rebase equivalents.src/__tests__/renderer/components/WorktreeMergeModal.test.tsxcovering default-branch preselection, excluding the worktree's own branch from the picker, merge and rebase dispatch, commit-first with the confirmed message, not merging when that commit fails, and conflict/error rendering.npm run lint,npm run lint:eslint, andprettier --check .all clean.Note: validated on macOS only. Needs both CI matrix legs green before merge.
Summary by CodeRabbit