feat: git worktree support - #12
Conversation
Worktrees in ~/projects/ with naming convention {repo}--{branch} are
automatically discovered as projects. Users can create/delete worktrees
from the git status dropdown on mobile, with branch suggestions and
grouped display in the project picker.
- Backend: worktree detection, CRUD functions, API endpoints
- GitStatus: inline worktree creation form + delete button
- ProjectPicker: worktrees grouped under parent repos
- ProjectTabs: branch name display for worktree tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move useMemo above early return in ProjectPicker (rules-of-hooks) - Replace control-char regex with charCodeAt check (no-control-regex) - Run prettier on all changed files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@akivab heads up — this PR adds git worktree support so you can work on multiple branches in parallel from the phone. Create/delete worktrees from the git status dropdown, and they show up grouped in the project picker. |
There was a problem hiding this comment.
Pull request overview
Adds Git worktree support across the backend store/API and client UI so users can create, discover, and manage multiple branch checkouts (worktrees) under ~/projects/ from mobile.
Changes:
- Detect worktrees during project discovery and expose
worktreemetadata + branch listings. - Add
/api/projects/:id/worktreesGET/POST/DELETE endpoints and enrich git status response for UI. - Update UI (GitStatus/ProjectPicker/Tabs/Chat) to create/delete worktrees and display grouped worktree projects.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/store.ts | Adds worktree detection plus branch listing and create/remove worktree functions. |
| server.ts | Adds worktree management endpoints and includes branch list + worktree metadata in git status response. |
| client/src/components/GitStatus.tsx | Adds UI to create/delete worktrees from the git status dropdown. |
| client/src/components/ProjectPicker.tsx | Groups worktrees under their parent repo and renders worktree rows indented. |
| client/src/components/ProjectTabs.tsx | Adds WorktreeInfo/worktree typing and displays worktree branch in tabs. |
| client/src/pages/Chat.tsx | Wires worktree create/delete callbacks into existing project open/close handlers. |
| ideas/WT.md | Adds implementation plan/notes for worktree support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| timeout: 5000, | ||
| stdio: "pipe", | ||
| }); | ||
| branchExists = true; | ||
| } catch { | ||
| // Try remote |
There was a problem hiding this comment.
execSync() is being used with unescaped/interpolated branch values, which enables shell injection and also allows branch names starting with - to be parsed as options. Prefer using an argument-array API (e.g., execFile/execFileSync) and pass -- before the ref name; additionally validate refs via git check-ref-format --branch <branch> (or equivalent) so the store layer is safe even if called outside the HTTP route validation.
| timeout: 5000, | ||
| stdio: "pipe", | ||
| }); | ||
| branchExists = true; | ||
| } catch { | ||
| // Branch doesn't exist anywhere — will be created new |
There was a problem hiding this comment.
execSync() is being used with unescaped/interpolated branch values, which enables shell injection and also allows branch names starting with - to be parsed as options. Prefer using an argument-array API (e.g., execFile/execFileSync) and pass -- before the ref name; additionally validate refs via git check-ref-format --branch <branch> (or equivalent) so the store layer is safe even if called outside the HTTP route validation.
|
|
||
| console.log(`[store] Creating worktree: ${cmd}`); | ||
| execSync(cmd, { | ||
| cwd: mainRepoPath, | ||
| encoding: "utf-8", | ||
| timeout: 30000, | ||
| }); | ||
|
|
||
| return { | ||
| id: worktreeId, |
There was a problem hiding this comment.
execSync() is being used with unescaped/interpolated branch values, which enables shell injection and also allows branch names starting with - to be parsed as options. Prefer using an argument-array API (e.g., execFile/execFileSync) and pass -- before the ref name; additionally validate refs via git check-ref-format --branch <branch> (or equivalent) so the store layer is safe even if called outside the HTTP route validation.
| cwd: mainRepoPath, | ||
| encoding: "utf-8", | ||
| timeout: 5000, | ||
| stdio: "pipe", | ||
| }); | ||
| branchExists = true; | ||
| } catch { | ||
| // Branch doesn't exist anywhere — will be created new | ||
| } | ||
| } | ||
|
|
||
| const cmd = branchExists | ||
| ? `git worktree add ${JSON.stringify(worktreePath)} ${branch}` | ||
| : `git worktree add -b ${branch} ${JSON.stringify(worktreePath)}`; | ||
|
|
||
| console.log(`[store] Creating worktree: ${cmd}`); | ||
| execSync(cmd, { |
There was a problem hiding this comment.
If the branch exists only on the remote (no local refs/heads/<branch>), setting branchExists = true and then running git worktree add <path> <branch> can fail (since the local branch may not exist). Track “local exists” vs “remote exists” separately and, for remote-only branches, create a local branch that tracks the remote (e.g., git worktree add -b <branch> <path> origin/<branch>).
| const content = readFileSync(gitPath, "utf8").trim(); | ||
| const match = content.match(/^gitdir:\s*(.+)$/); | ||
| if (!match) return null; | ||
|
|
||
| const gitdir = match[1]; | ||
| // Navigate from .git/worktrees/<name> up to the main .git dir | ||
| const gitMainDir = gitdir.replace(/\/worktrees\/[^/]+$/, ""); | ||
| // The main repo path is the parent of the .git dir | ||
| const mainWorktreePath = resolve(join(gitMainDir, "..")); | ||
| const parentRepoId = basename(mainWorktreePath); |
There was a problem hiding this comment.
.git “gitdir:” paths can be relative to the worktree directory; treating match[1] as absolute and running resolve(join(gitMainDir, "..")) will resolve relative to process.cwd() instead of the worktree. Resolve gitdir relative to dirPath first (when not absolute) before computing gitMainDir / mainWorktreePath.
| // Use listProjects and filter to worktrees of this repo | ||
| const mainRepoId = project.worktree | ||
| ? project.worktree.parentRepoId | ||
| : project.id; | ||
| const allProjects = listProjects(); | ||
| const worktrees = allProjects.filter( | ||
| (p) => p.worktree?.parentRepoId === mainRepoId || p.id === mainRepoId, | ||
| ); | ||
| console.log( |
There was a problem hiding this comment.
GET /worktrees currently scans the entire ~/projects directory via listProjects(), and listProjects() may run git commands for each detected worktree; this can become slow as the number of projects grows. Prefer deriving worktrees from the repo itself (e.g., git worktree list --porcelain run once in the main repo) and mapping results to project IDs/paths instead of scanning all projects.
| const worktrees = allProjects.filter( | ||
| (p) => p.worktree?.parentRepoId === mainRepoId || p.id === mainRepoId, | ||
| ); | ||
| console.log( | ||
| `[api] Listed ${worktrees.length} worktrees for ${projectId}`, | ||
| ); | ||
| return json(res, { worktrees }); | ||
| } catch (err) { |
There was a problem hiding this comment.
The worktrees response includes the main repo entry (p.id === mainRepoId) in addition to actual worktrees, which is semantically confusing for API consumers. Either (a) exclude the main repo from worktrees, (b) return { main: Project, worktrees: Project[] }, or (c) rename the field to something like repoProjects to match the actual contents.
| const worktrees = allProjects.filter( | |
| (p) => p.worktree?.parentRepoId === mainRepoId || p.id === mainRepoId, | |
| ); | |
| console.log( | |
| `[api] Listed ${worktrees.length} worktrees for ${projectId}`, | |
| ); | |
| return json(res, { worktrees }); | |
| } catch (err) { | |
| const relatedProjects = allProjects.filter( | |
| (p) => | |
| p.worktree?.parentRepoId === mainRepoId || p.id === mainRepoId, | |
| ); | |
| const main = relatedProjects.find((p) => p.id === mainRepoId) ?? project; | |
| const worktrees = relatedProjects.filter((p) => p.id !== mainRepoId); | |
| console.log( | |
| `[api] Listed ${worktrees.length} worktrees for ${projectId}`, | |
| ); | |
| return json(res, { main, worktrees }); |
| // Use listProjects and filter to worktrees of this repo | ||
| const mainRepoId = project.worktree | ||
| ? project.worktree.parentRepoId | ||
| : project.id; | ||
| const allProjects = listProjects(); | ||
| const worktrees = allProjects.filter( | ||
| (p) => p.worktree?.parentRepoId === mainRepoId || p.id === mainRepoId, | ||
| ); | ||
| console.log( | ||
| `[api] Listed ${worktrees.length} worktrees for ${projectId}`, | ||
| ); | ||
| return json(res, { worktrees }); | ||
| } catch (err) { |
There was a problem hiding this comment.
The worktrees response includes the main repo entry (p.id === mainRepoId) in addition to actual worktrees, which is semantically confusing for API consumers. Either (a) exclude the main repo from worktrees, (b) return { main: Project, worktrees: Project[] }, or (c) rename the field to something like repoProjects to match the actual contents.
| // Use listProjects and filter to worktrees of this repo | |
| const mainRepoId = project.worktree | |
| ? project.worktree.parentRepoId | |
| : project.id; | |
| const allProjects = listProjects(); | |
| const worktrees = allProjects.filter( | |
| (p) => p.worktree?.parentRepoId === mainRepoId || p.id === mainRepoId, | |
| ); | |
| console.log( | |
| `[api] Listed ${worktrees.length} worktrees for ${projectId}`, | |
| ); | |
| return json(res, { worktrees }); | |
| } catch (err) { | |
| // Use listProjects and separate main repo from its worktrees | |
| const mainRepoId = project.worktree | |
| ? project.worktree.parentRepoId | |
| : project.id; | |
| const allProjects = listProjects(); | |
| const main = allProjects.find((p) => p.id === mainRepoId); | |
| const worktrees = allProjects.filter( | |
| (p) => p.worktree?.parentRepoId === mainRepoId, | |
| ); | |
| console.log( | |
| `[api] Listed ${worktrees.length} worktrees for ${projectId}`, | |
| ); | |
| return json(res, { main, worktrees }); |
| export interface WorktreeInfo { | ||
| isWorktree: true; | ||
| parentRepoId: string; | ||
| branch: string; | ||
| mainWorktreePath: string; | ||
| } |
There was a problem hiding this comment.
WorktreeInfo is now defined in both the server/store layer (src/lib/store.ts) and the client (ProjectTabs.tsx), which risks the two drifting over time. Consider defining this shape once in a shared type module (or generating types from the API contract) and importing it in both places.
Summary
~/projects/with naming convention{repo}--{branch}and are auto-discoveredChanges
detectWorktree), branch listing, create/remove functions/api/projects/:id/worktrees+ enhanced git status with branches listWorktreeInfotype, branch name display + git icon for worktree tabsonWorktreeCreated/onWorktreeDeletedcallbacksTest plan
🤖 Generated with Claude Code