Skip to content

feat: git worktree support - #12

Merged
jamierpond merged 4 commits into
mainfrom
feature/worktree-support
Feb 10, 2026
Merged

feat: git worktree support#12
jamierpond merged 4 commits into
mainfrom
feature/worktree-support

Conversation

@jamierpond

Copy link
Copy Markdown
Owner

Summary

  • Add git worktree support so you can work on multiple branches simultaneously from your phone
  • Worktrees live in ~/projects/ with naming convention {repo}--{branch} and are auto-discovered
  • Create/delete worktrees from the git status dropdown (branch badge)
  • Project picker groups worktrees under their parent repo with branch icons

Changes

  • store.ts: Worktree detection (detectWorktree), branch listing, create/remove functions
  • server.ts: GET/POST/DELETE /api/projects/:id/worktrees + enhanced git status with branches list
  • GitStatus.tsx: "New worktree" creation form with branch suggestions + "Delete worktree" button
  • ProjectPicker.tsx: Grouped display with worktrees indented under parent repos
  • ProjectTabs.tsx: WorktreeInfo type, branch name display + git icon for worktree tabs
  • Chat.tsx: Wire onWorktreeCreated/onWorktreeDeleted callbacks

Test plan

  • Open a project, tap branch badge, see "New worktree" button
  • Create worktree for new branch — new tab opens with that branch
  • Send a message in the worktree — Claude works in the correct directory
  • Open project picker — worktrees grouped under parent repo
  • Delete worktree — tab closes, directory removed
  • Verify main repo unaffected after worktree deletion

🤖 Generated with Claude Code

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>
@jamierpond
jamierpond requested a review from Copilot February 9, 2026 16:16
- 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>
@jamierpond

Copy link
Copy Markdown
Owner Author

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

Copilot AI 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.

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 worktree metadata + branch listings.
  • Add /api/projects/:id/worktrees GET/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.

Comment thread src/lib/store.ts
Comment on lines +655 to +660
timeout: 5000,
stdio: "pipe",
});
branchExists = true;
} catch {
// Try remote

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/store.ts
Comment on lines +665 to +670
timeout: 5000,
stdio: "pipe",
});
branchExists = true;
} catch {
// Branch doesn't exist anywhere — will be created new

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/store.ts
Comment on lines +677 to +686

console.log(`[store] Creating worktree: ${cmd}`);
execSync(cmd, {
cwd: mainRepoPath,
encoding: "utf-8",
timeout: 30000,
});

return {
id: worktreeId,

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/store.ts
Comment on lines +663 to +679
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, {

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Comment thread src/lib/store.ts
Comment on lines +366 to +375
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);

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Comment thread server.ts
Comment on lines +747 to +755
// 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(

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread server.ts
Comment on lines +752 to +759
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) {

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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 });

Copilot uses AI. Check for mistakes.
Comment thread server.ts
Comment on lines +747 to +759
// 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) {

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
// 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 });

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +8
export interface WorktreeInfo {
isWorktree: true;
parentRepoId: string;
branch: string;
mainWorktreePath: string;
}

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@jamierpond
jamierpond merged commit fe950e9 into main Feb 10, 2026
4 checks passed
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.

2 participants