Skip to content

feat(worktree): merge and rebase actions in the worktree context menu - #1309

Open
pedramamini wants to merge 1 commit into
rcfrom
feat/263-worktree-merge-rebase-buttons
Open

feat(worktree): merge and rebase actions in the worktree context menu#1309
pedramamini wants to merge 1 commit into
rcfrom
feat/263-worktree-merge-rebase-buttons

Conversation

@pedramamini

@pedramamini pedramamini commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Merge Branch Into... - merge this worktree's branch into a branch you pick
  • Rebase Branch Onto... - replay this worktree's branch on top of a branch you pick, to pull in new upstream work

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:mergeBranch runs in whichever worktree currently has the target branch checked out, located via git 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 runs git merge --abort and returns the conflicting paths. A single button press should never leave someone's main checkout stuck half-merged.

git:rebaseBranch runs 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, WorktreeMergeModal detects uncommitted changes in the worktree and offers to commit them first with an editable message, reusing the existing git:commitAll handler. 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. execGit already uses execFile (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:

  • Auto-commit when a task completes. This needs a decision about where it hooks in (agent-idle transition? Auto Run iteration boundary?) and whether it is opt-in per agent. Worth its own issue.
  • Generating commit messages with a cheaper model (e.g. DeepSeek). Maestro has no separate "utility model" configuration today, so this means new settings surface rather than a small addition. The modal's message field is hand-editable in the meantime.

Choosing the base branch when creating a worktree already works (CreateWorktreeModal has a Base Branch picker); this PR adds the matching choice on the merge/rebase side.

Testing

  • 11 new handler tests in src/__tests__/main/ipc/handlers/git.test.ts covering: 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.
  • 7 new component tests in src/__tests__/renderer/components/WorktreeMergeModal.test.tsx covering 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.
  • Full suite: 34,665 passed, 108 skipped. npm run lint, npm run lint:eslint, and prettier --check . all clean.

Note: validated on macOS only. Needs both CI matrix legs green before merge.

Summary by CodeRabbit

  • New Features
    • Added options to merge a worktree branch into another branch or rebase it onto a selected branch.
    • Added a guided modal for selecting branches, committing pending changes, and reviewing operation results.
    • Added conflict reporting with affected file paths and clear success, error, and up-to-date messages.
    • Added support for performing merge and rebase operations on remote worktrees.
  • Bug Fixes
    • Prevented operations on uncommitted worktrees, invalid branch names, missing branches, and self-merges.

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.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Worktree merge and rebase workflows now include Git IPC handlers, preload and renderer service APIs, modal-store state, context-menu actions, and a new WorktreeMergeModal. The flow supports dirty-tree commits, SSH execution, conflict reporting, abort handling, validation, and refreshes after successful completion.

Worktree Git operations

Layer / File(s) Summary
Git operations and IPC contracts
src/main/ipc/handlers/git.ts, src/main/preload/git.ts, src/__tests__/main/ipc/handlers/git.test.ts
Adds SSH-aware merge and rebase handlers with branch validation, worktree lookup, dirty-tree checks, conflict detection, abort handling, and structured results.
Renderer API and modal routing
src/renderer/services/git.ts, src/renderer/stores/modalStore.ts, src/renderer/hooks/worktree/useWorktreeHandlers.ts, src/renderer/components/AppModals/*, src/renderer/App.tsx, src/renderer/global.d.ts
Exposes Git operations through renderer APIs and routes worktree merge/rebase modal state and callbacks through the application.
Merge and rebase modal
src/renderer/components/WorktreeMergeModal.tsx, src/__tests__/renderer/components/WorktreeMergeModal.test.tsx, src/__tests__/setup.ts
Adds branch selection, dirty-change commit handling, merge/rebase execution, and success, conflict, and error displays with tests.
Worktree action entry points
src/renderer/components/SessionList/*, src/renderer/hooks/props/useSessionListProps.ts
Adds merge and rebase actions to child worktree context menus and wires them to modal-opening handlers.

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
Loading

Possibly related PRs

Suggested labels: ready to merge

Suggested reviewers: reachrazamair, chr1syy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers merge/rebase actions and commit-message editing, but it omits auto-commit on task completion and cheaper-model commit-message generation requested in #263. Add automatic task-completion commits and optional cheaper-model commit-message generation, or split them into separate follow-up issues if intentionally deferred.
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding merge and rebase worktree actions in the context menu.
Out of Scope Changes check ✅ Passed All changes support the worktree merge/rebase workflow or its UI/API plumbing; no unrelated code paths are introduced.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/263-worktree-merge-rebase-buttons

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

Adds worktree merge and rebase actions across the renderer, preload bridge, and main-process Git handlers.

  • Adds context-menu actions and a confirmation modal with branch selection and optional commit-first behavior.
  • Adds SSH-aware merge and rebase IPC handlers with clean-tree checks and conflict abort handling.
  • Adds modal state wiring, preload and renderer service APIs, and handler/component tests.

Confidence Score: 2/5

The 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

Filename Overview
src/main/ipc/handlers/git.ts Adds merge and rebase handlers, but merge cleanup is skipped when a failed merge leaves state without unmerged paths.
src/renderer/components/WorktreeMergeModal.tsx Adds the merge/rebase workflow UI, but drops the SSH remote directory and presents remote-only refs as valid merge targets.
src/renderer/services/git.ts Adds renderer wrappers for the new IPC methods but does not expose or forward remoteCwd.
src/main/preload/git.ts Adds matching preload methods with consistent channel names and argument ordering.
src/renderer/stores/modalStore.ts Registers typed modal state and actions for the merge/rebase workflow.
src/tests/main/ipc/handlers/git.test.ts Covers primary local outcomes but omits SSH remote-directory behavior and failed merges without conflict paths.
src/tests/renderer/components/WorktreeMergeModal.test.tsx Covers the main local modal workflow but omits SSH paths and remote-only branch options.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "feat(worktree): merge and rebase actions..." | Re-trigger Greptile

Comment on lines +89 to +91
gitService.getBranches(session.cwd, sshRemoteId),
window.maestro.git.getDefaultBranch(session.cwd),
gitService.getStatus(session.cwd, sshRemoteId),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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

Comment on lines +410 to +415
// 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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

Comment on lines +97 to +105
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] || '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 81e145f and 71ad3d2.

📒 Files selected for processing (17)
  • src/__tests__/main/ipc/handlers/git.test.ts
  • src/__tests__/renderer/components/WorktreeMergeModal.test.tsx
  • src/__tests__/setup.ts
  • src/main/ipc/handlers/git.ts
  • src/main/preload/git.ts
  • src/renderer/App.tsx
  • src/renderer/components/AppModals/AppModals.tsx
  • src/renderer/components/AppModals/AppWorktreeModals.tsx
  • src/renderer/components/SessionList/SessionContextMenu.tsx
  • src/renderer/components/SessionList/SessionList.tsx
  • src/renderer/components/WorktreeMergeModal.tsx
  • src/renderer/constants/modalPriorities.ts
  • src/renderer/global.d.ts
  • src/renderer/hooks/props/useSessionListProps.ts
  • src/renderer/hooks/worktree/useWorktreeHandlers.ts
  • src/renderer/services/git.ts
  • src/renderer/stores/modalStore.ts

Comment on lines +251 to +261
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,
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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 code 0 for merge --abort.
  • src/__tests__/main/ipc/handlers/git.test.ts#L397-L405: return exit code 0 for rebase --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.

Comment on lines +401 to +419
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',
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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.

Suggested change
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.

Comment on lines +52 to +55
useModalLayer(MODAL_PRIORITIES.WORKTREE_MERGE, undefined, () => onCloseRef.current(), {
focusTrap: 'lenient',
enabled: isOpen,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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.

Comment on lines +57 to +63
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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.

Comment on lines +84 to +117
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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.

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant