feat: launch Claude session as git worktree (⌘+Shift+Enter)#119
feat: launch Claude session as git worktree (⌘+Shift+Enter)#119
Conversation
- New ⌘+Shift+Enter shortcut on a project → opens a small dialog for an optional branch name. With a name we run `claude -w "<name>" -n "<name>"` in the configured terminal. - The `-n` flag is critical: it sets a Claude session custom title so the Sessions tab can switch back to the right tab in Ghostty (which has no per-tab TTY exposure). Without it, worktree and main-repo terminals share the same cwd and AppleScript first-match-wins picks the wrong tab. - Added parseWorktreePath() helper + isWorktree / parentRepo fields on ClaudeSession. Worktree sessions display as the parent repo name with a small WT badge, instead of the worktree folder name (e.g. "codev WT" not "test-worktree-4"). - Fixed Ghostty switch AppleScript: `activate` is now inside the match success branch, so we no longer surface the wrong Ghostty window when no tab matches. This benefits all session switches, not only worktrees. - Updated README + design doc + changelog. Version 1.0.75. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds git worktree session launching (optional name via dialog, shortcut ⌘+Shift+Enter) and worktree-aware session detection/display (parent repo + Changes
Sequence DiagramsequenceDiagram
participant User as User
participant UI as Switcher UI
participant Dialog as Worktree Dialog
participant Preload as Preload (window.electronAPI)
participant Main as Main Process
participant Util as Session Utility
participant Terminal as Terminal App
User->>UI: Select project + Cmd+Shift+Enter
UI->>Dialog: Open worktree name dialog
Dialog->>User: Prompt for worktree name
User->>Dialog: Enter name (or leave empty)
alt Worktree name provided
Dialog->>Preload: launchNewClaudeSessionWorktree(projectPath, worktreeName)
Preload->>Main: IPC: launch-new-claude-session-worktree
Main->>Util: launchNewClaudeSession(projectPath, ..., worktreeName)
Util->>Terminal: Execute: claude -w "<name>" -n "<name>"
else Empty input
Dialog->>Preload: launchNewClaudeSession(projectPath)
Preload->>Main: IPC: launch-new-claude-session
Main->>Util: launchNewClaudeSession(projectPath)
Util->>Terminal: Execute: claude (normal)
end
Terminal-->>User: New session / worktree created
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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 |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
src/claude-session-utility.ts (1)
161-176: DRY: usegetProjectDisplayNameinstead of re-inlining the worktree/basename logic.The new
getProjectDisplayNamehelper (lines 46‑50) implements exactly this fallback, but the same expression is reproduced here and at lines 863‑865 and 947‑949. Routing all three call sites through the helper avoids drift if the display rule ever changes (e.g., showing<repo>/<worktree>later).♻️ Proposed refactor
.map((s) => { const worktree = parseWorktreePath(s.project); return { sessionId: s.sessionId, project: s.project, - projectName: worktree - ? path.basename(worktree.parentRepo) || worktree.parentRepo - : (path.basename(s.project) || s.project), + projectName: getProjectDisplayName(s.project), firstUserMessage: s.firstDisplay, lastUserMessage: s.lastDisplay, lastTimestamp: s.lastTimestamp, messageCount: s.promptCount, isActive: false, ...(worktree && { isWorktree: true, parentRepo: worktree.parentRepo }), }; });Apply the same swap at lines 863‑865 and 947‑949.
As per coding guidelines:
src/**/*.{ts,tsx}: Use TypeScript for all components with strict typing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/claude-session-utility.ts` around lines 161 - 176, The map callback in src/claude-session-utility.ts duplicates the worktree/basename fallback logic to compute projectName; replace the inline expression with a call to the existing helper getProjectDisplayName (e.g., const projectName = getProjectDisplayName(s.project, worktree)) and use that for the projectName property, keeping the existing isWorktree/parentRepo spread; apply the same replacement at the other two places in this file where the same basename/worktree expression is duplicated so all three call sites route through getProjectDisplayName.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/claude-session-utility.ts`:
- Around line 1389-1396: The code interpolates unvalidated worktreeName into the
shell command built in claude-session-utility (shortCmd/fullCmd) and launches it
via runCommandInTerminal, which allows shell expansion/command injection; to
fix, add validation in the UI and sanitize/quote before constructing fullCmd:
either enforce a whitelist/regex in the switcher UI (e.g.,
/^[A-Za-z0-9._\/\-]+$/) in launchNewClaudeSessionWorktree so invalid names are
rejected with user feedback, or apply robust shell-quoting/escaping when
building shortCmd/fullCmd (escape all special chars including ", $, `, \\, ;, &,
|, $( )) so the argument is passed literally to claude, and ensure both the
dialog (switcher-ui.tsx) and the claude-session-utility path perform the same
check/escape to prevent bypass.
In `@src/main.ts`:
- Around line 2152-2159: The ipcMain handler passes raw worktreeName into
launchNewClaudeSession which constructs a shell string like claude -w
"${worktreeName}" -n "${worktreeName}", allowing injection via
quotes/backticks/$(); either validate/sanitize worktreeName in the ipc handler
(reject or strip characters like " ' ` $ ( ) and newlines, enforce a safe regex
e.g. /^[A-Za-z0-9._-]+$/) or change launchNewClaudeSession to avoid shell
interpolation by using child_process.spawn/execFile with an args array (e.g.
['-w', worktreeName, '-n', worktreeName]) so the value is passed as an argument
not parsed by the shell; update the ipcMain.on callback to perform the
validation or pass the raw value to the new spawn-style API accordingly.
In `@src/switcher-ui.tsx`:
- Around line 1520-1523: The dropdown hint in the DropdownIndicator component
currently only advertises worktree and external-launch shortcuts; update the
hint string inside DropdownIndicator to also include the existing Shift+Enter
CodeV (embedded terminal) shortcut so it matches the actual supported shortcuts
(the same shortcut referenced around lines 1491-1492). Locate the
DropdownIndicator function/component and append or restore the "Shift+Enter"
text into the displayed string (keeping existing symbols like '\u2318' and
formatting) so users see the embedded terminal shortcut alongside the worktree
and New Claude hints.
- Around line 1682-1797: The modal's clicks and key events are bubbling to
global document handlers (causing focus steal); update the modal container and
inner dialog to stop propagation and trap focus: add onClick={(e) => {
e.stopPropagation(); if (e.target === e.currentTarget) setWorktreeDialog(null);
}} and add onKeyDown={(e) => e.stopPropagation()} on the outer fixed container
(and/or the inner dialog wrapper), and when showing the dialog call
worktreeInputRef.current?.focus() so the branch input receives focus; target the
existing worktreeDialog rendering, the outer fixed div and the inner dialog div
and the worktreeInputRef / input onKeyDown handlers to implement these changes.
---
Nitpick comments:
In `@src/claude-session-utility.ts`:
- Around line 161-176: The map callback in src/claude-session-utility.ts
duplicates the worktree/basename fallback logic to compute projectName; replace
the inline expression with a call to the existing helper getProjectDisplayName
(e.g., const projectName = getProjectDisplayName(s.project, worktree)) and use
that for the projectName property, keeping the existing isWorktree/parentRepo
spread; apply the same replacement at the other two places in this file where
the same basename/worktree expression is duplicated so all three call sites
route through getProjectDisplayName.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: 31b3c16a-2a46-41b4-be08-ee9bd2d78a07
📒 Files selected for processing (9)
CHANGELOG.mdREADME.mddocs/claude-session-integration-design.mdpackage.jsonsrc/claude-session-utility.tssrc/electron-api.d.tssrc/main.tssrc/preload.tssrc/switcher-ui.tsx
There was a problem hiding this comment.
3 issues found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/claude-session-utility.ts">
<violation number="1" location="src/claude-session-utility.ts:1393">
P1: Unsanitized `worktreeName` is interpolated into a shell command, enabling command injection in terminal launch paths.</violation>
</file>
<file name="src/main.ts">
<violation number="1" location="src/main.ts:2159">
P1: Validate or sanitize `worktreeName` before passing it to the launcher; currently it can break out of the quoted shell argument and inject commands.</violation>
</file>
<file name="src/switcher-ui.tsx">
<violation number="1" location="src/switcher-ui.tsx:1694">
P2: The worktree dialog does not isolate keyboard events, so pressing Tab in the dialog triggers the app-level tab switch shortcut instead of moving focus within the modal.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
- Critical: validate worktreeName before shell interpolation.
isValidWorktreeName() rejects shell metacharacters and bad
branch-name patterns. Validated at three layers:
* dialog input (visual error + Launch button disabled)
* IPC handler (defense in depth)
* launchNewClaudeSession (defense in depth)
- Major: dialog now traps keydown + click events so background
shortcuts (Tab, Esc-document, etc.) and project-input refocus
can't fire while the modal is open. Added role="dialog" +
aria-modal for a11y.
- Minor: hint text now includes Shift+Enter (Codev embedded
terminal) so it's discoverable next to the worktree shortcut.
- Docs: noted the IDE/git-GUI clutter trade-off of nested
worktrees, that A and B aren't mutually exclusive, and that
-n becomes optional once Ghostty exposes per-tab TTY.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/claude-session-utility.ts (1)
1380-1429:⚠️ Potential issue | 🟠 MajorWorktree launches are silently downgraded for
vscodeandcodev.
worktreeNameis only used after the earlyvscode/codevreturns, so users with either terminal configured still get a plain session even though they went through the new worktree dialog.src/main.tsnow forwards the configured terminal into this function, so this path is reachable in normal use. Please either plumbworktreeNamethrough those backends or fail fast instead of ignoring it.Safe stop-gap
export const launchNewClaudeSession = ( projectPath: string, terminalApp: string = 'iterm2', terminalMode: string = 'tab', worktreeName?: string, ): void => { + if (worktreeName && (terminalApp === 'vscode' || terminalApp === 'codev')) { + console.error( + '[launchNewClaudeSession] worktree launch is not implemented for terminalApp:', + terminalApp, + ); + return; + } if (terminalApp === 'vscode') {
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/claude-session-utility.ts`:
- Around line 35-40: parseWorktreePath currently uses a regex that only captures
a single path segment for the worktree name, breaking names containing slashes
(e.g., fix/login-bug); update parseWorktreePath to use a regex that captures the
entire remainder of the path as the worktreeName while stopping at the
`/.claude/worktrees/` boundary for parentRepo (for example change the pattern to
something like /^(.+?)\/\.claude\/worktrees\/(.+?)\/?$/), then return parentRepo
and worktreeName accordingly (no code formatting changes beyond replacing the
regex), and keep in mind isValidWorktreeName allows slash-delimited names.
In `@src/main.ts`:
- Around line 2153-2165: The handler registered via
ipcMain.on('launch-new-claude-session-worktree') currently only checks
existsSync(projectPath) and can accept files (.code-workspace); change the
validation to reject non-directory paths and non-git repositories before calling
launchNewClaudeSession: use fs.statSync(projectPath).isDirectory() (or
lstatSync) to ensure it's a directory, and then verify it's a git
repo/worktree-capable repo (for example by running a quick git check such as
executing "git rev-parse --is-inside-work-tree" or checking for a .git
directory) and return with an error log (similar to the existing
isValidWorktreeName handling) if either check fails; keep the
isValidWorktreeName check and only call launchNewClaudeSession when all
validations pass.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8f56ddd9-aa0c-4c15-bcc3-8e6b2ec771a6
📒 Files selected for processing (4)
docs/claude-session-integration-design.mdsrc/claude-session-utility.tssrc/main.tssrc/switcher-ui.tsx
✅ Files skipped from review due to trivial changes (1)
- docs/claude-session-integration-design.md
| export const parseWorktreePath = ( | ||
| p: string, | ||
| ): { parentRepo: string; worktreeName: string } | null => { | ||
| if (!p) return null; | ||
| const match = p.match(/^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/); | ||
| return match ? { parentRepo: match[1], worktreeName: match[2] } : null; |
There was a problem hiding this comment.
parseWorktreePath() breaks on slash-delimited worktree names.
isValidWorktreeName() explicitly allows names like fix/login-bug, but this regex only captures a single segment after /.claude/worktrees/. For a path like /repo/.claude/worktrees/fix/login-bug, it misidentifies the parent repo as /repo/.claude/worktrees/fix, so the new projectName/isWorktree metadata is wrong across history reads and active-session detection.
Suggested fix
export const parseWorktreePath = (
p: string,
): { parentRepo: string; worktreeName: string } | null => {
if (!p) return null;
- const match = p.match(/^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/);
- return match ? { parentRepo: match[1], worktreeName: match[2] } : null;
+ const marker = `${path.sep}.claude${path.sep}worktrees${path.sep}`;
+ const idx = p.indexOf(marker);
+ if (idx === -1) return null;
+ const parentRepo = p.slice(0, idx);
+ const worktreeName = p.slice(idx + marker.length).replace(/\/$/, '');
+ if (!parentRepo || !worktreeName) return null;
+ return { parentRepo, worktreeName };
};🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/claude-session-utility.ts` around lines 35 - 40, parseWorktreePath
currently uses a regex that only captures a single path segment for the worktree
name, breaking names containing slashes (e.g., fix/login-bug); update
parseWorktreePath to use a regex that captures the entire remainder of the path
as the worktreeName while stopping at the `/.claude/worktrees/` boundary for
parentRepo (for example change the pattern to something like
/^(.+?)\/\.claude\/worktrees\/(.+?)\/?$/), then return parentRepo and
worktreeName accordingly (no code formatting changes beyond replacing the
regex), and keep in mind isValidWorktreeName allows slash-delimited names.
| ipcMain.on('launch-new-claude-session-worktree', async (_event, projectPath: string, worktreeName: string) => { | ||
| if (!existsSync(projectPath)) { | ||
| console.log('[launch-new-claude-session-worktree] path does not exist:', projectPath); | ||
| return; | ||
| } | ||
| // Validate at IPC boundary (defense in depth) — the renderer also validates. | ||
| if (!isValidWorktreeName(worktreeName)) { | ||
| console.error('[launch-new-claude-session-worktree] invalid worktreeName:', JSON.stringify(worktreeName)); | ||
| return; | ||
| } | ||
| const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; | ||
| const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; | ||
| launchNewClaudeSession(projectPath, terminalApp, terminalMode, worktreeName); |
There was a problem hiding this comment.
Reject non-directory targets before starting a worktree session.
This handler only checks existsSync(projectPath), so .code-workspace entries from the Projects list still pass through. The downstream launcher then ends up doing cd "<workspace-file>" && claude -w ..., which fails before Claude starts. Please require a directory here, and ideally a git worktree-capable repo, before forwarding the request.
Suggested hardening
-import { existsSync, readdirSync } from 'fs';
+import { existsSync, readdirSync, statSync } from 'fs';
...
ipcMain.on('launch-new-claude-session-worktree', async (_event, projectPath: string, worktreeName: string) => {
if (!existsSync(projectPath)) {
console.log('[launch-new-claude-session-worktree] path does not exist:', projectPath);
return;
}
+ if (!statSync(projectPath).isDirectory()) {
+ console.error('[launch-new-claude-session-worktree] projectPath is not a directory:', projectPath);
+ return;
+ }
// Validate at IPC boundary (defense in depth) — the renderer also validates.
if (!isValidWorktreeName(worktreeName)) {
console.error('[launch-new-claude-session-worktree] invalid worktreeName:', JSON.stringify(worktreeName));
return;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main.ts` around lines 2153 - 2165, The handler registered via
ipcMain.on('launch-new-claude-session-worktree') currently only checks
existsSync(projectPath) and can accept files (.code-workspace); change the
validation to reject non-directory paths and non-git repositories before calling
launchNewClaudeSession: use fs.statSync(projectPath).isDirectory() (or
lstatSync) to ensure it's a directory, and then verify it's a git
repo/worktree-capable repo (for example by running a quick git check such as
executing "git rev-parse --is-inside-work-tree" or checking for a .git
directory) and return with an error log (similar to the existing
isValidWorktreeName handling) if either check fails; keep the
isValidWorktreeName check and only call launchNewClaudeSession when all
validations pass.
Summary
Add
⌘+Shift+Enteron the Projects tab to launch a Claude Code session as a git worktree. Plus polish: worktree-aware naming.What's new
Launch a worktree session —
⌘+Shift+EnterA small dialog asks for an optional branch name:
⌘+Enter(normal session)claude -w "<name>" -n "<name>"in the configured terminal. Claude CLI creates a fresh worktree at<repo>/.claude/worktrees/<name>and starts the session inside it.Esccancels, click outside cancels,Enterlaunches. Modal traps Tab/click events so background shortcuts/inputs don't fire while the dialog is open.Worktree session display
Sessions whose path is
<repo>/.claude/worktrees/<name>now display:codev) — not the worktree folder name (e.g.,test-worktree-4)WTbadgeNew
parseWorktreePath()helper +isWorktree/parentRepofields onClaudeSession. Theprojectfield still points at the worktree path (resume / cwd matching unchanged). Detection is path-based: also recognizes worktree sessions launched manually viaclaude -wor by other tools.Why
-n <name>is requiredWithout
-n, Ghostty session switching breaks for worktree sessions:Passing
-n <name>makes Claude CLI write acustom-titleJSONL entry. Codev's existing Layer-1 title match finds the correct tab. iTerm2 / Terminal.app / cmux were already unaffected (they use TTY match).Future: once Ghostty exposes per-tab TTY (ghostty-org/ghostty#11354), the
-nflag becomes optional.Shell-injection guard
worktreeNameis interpolated into a shell command that ends up in AppleScript. Validated at three layers (dialog input, IPC handler,launchNewClaudeSession) viaisValidWorktreeName()— whitelist of branch-name-safe characters; rejects shell metacharacters, leading dash/dot/slash, double slash,...Why we use
claude -winstead ofgit worktree addourselvesTwo approaches were considered. We picked B but A remains a viable future direction.
<parent>/<repo>-<branch>(sibling)git worktree addourselvesclaude -w <name> -n <name>(this PR)<repo>/.claude/worktrees/<name>(nested)--tmux). Downside: the nested worktree folder is visible to VS Code / git GUI, so users may see worktree files in their workspace, accidentally commit them, or include them in cross-repo searches.Why B for now: codev is Claude-Code-focused (claude-control supports multiple AI CLIs, so they need their own implementation). Auto-cleanup is the right default for codev's typical use case. Detection of
<repo>/.claude/worktrees/paths is path-based, so even if we ever add A as an option, existing nested worktrees keep showing theWTbadge.See the design doc update for the full discussion.
Files
src/switcher-ui.tsx— new dialog component,⌘+Shift+Enterkeybinding, WT badge, hint text, modal event isolation, name validationsrc/claude-session-utility.ts—parseWorktreePath(),isValidWorktreeName(), worktree fields in 3 session sources,-nin launchsrc/main.ts,src/preload.ts,src/electron-api.d.ts— newlaunch-new-claude-session-worktreeIPC with validationdocs/claude-session-integration-design.md— added "Git Worktree Sessions" sectionREADME.md,CHANGELOG.md,package.json— feature doc + version 1.0.75Test plan
⌘+Shift+Enteropens dialog⌘+Enter)claude -w … -n …launches in external terminalEsc/ click-outside cancelyarn makebuild OK (no debug logs in production)Known limitations / future work
These are pre-existing issues that became more visible with worktree sessions but are not specific to this PR. Documented for follow-up:
Same-cwd switch ambiguity (Ghostty). When multiple Ghostty windows report the same cwd (e.g., a
yarn startwindow + the active conversation window + an old worktree window all at~/git/codev), Layer-2 cwd match returns the first window in iteration order — often the top-most. Workaround: use-n//renameto disambiguate via title match. Real fix waits on Ghostty TTY exposure.Status indicator occasionally orange when expected green. Worktree sessions that should be idle sometimes show the working/needs-attention orange dot. Cause TBD — may be hook timing or status-file race. Tracked separately.
Old worktree sessions launched without
-n(before this PR) won't have a custom title and will still hit the cwd-collision issue when switching. Workaround: exit + re-launch.Non-alive worktree sessions don't show a branch name. Claude CLI may auto-cleanup the worktree directory on clean exit, breaking the
git -C <worktree-path> branchlookup. Branch info for cleaned-up worktrees is a future enhancement.Nested worktree visibility in IDE/git GUI. Because
claude -wplaces worktrees at<repo>/.claude/worktrees/<name>, VS Code's file explorer and git GUI tools see those folders inside the workspace. Mitigation: add.claude/worktrees/to.gitignore, or revisit Approach A in a future PR.AppleScript
activatetiming investigated, kept top-of-block. Movingactivateinto match-success branches showed a perceptible slowdown in switch latency in local testing. Reverted; the visual glitch on match-miss is documented as a manifestation of the same-cwd ambiguity above, not a separate bug.Resume with worktree-aware context. Codev resumes worktree sessions via
cd <path> && claude --resume <uuid>. Claude CLI also supportsclaude --worktree <name> --resume <uuid>which preserves keep/remove prompts on next exit. Codev could detectisWorktreeand use the worktree-aware form — future enhancement.Drop
-nonce Ghostty exposes TTY (ghostty#11354).🤖 On behalf of @grimmerk — generated with Claude Code