Skip to content

feat(worktrees): create, open and remove git worktrees of a case - #625

Open
Ark0N wants to merge 6 commits into
masterfrom
feat/git-worktrees
Open

Ark0N wants to merge 6 commits into
masterfrom
feat/git-worktrees

Conversation

@Ark0N

@Ark0N Ark0N commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Codeman runs several agent sessions per case, and every session in a case shares the case folder. For a git repository that is a problem as soon as two agents work at once: they edit the same files, overwrite each other's builds and commit each other's half-finished work. This PR lets Codeman create, open and remove git worktrees for a local case whose folder is a repository, so each agent gets its own checkout on its own branch, and start sessions inside them through the existing launch paths. It does not touch Docker or remote (SSH) cases (both are refused with a 400 before any git runs), the tracked .gitignore of a repository, or how sessions outside a worktree are created.

How it works

  • Layout. Worktrees live at <repo>/.claude/worktrees/<name> on a new branch worktree-<name>. That is the layout Claude Code's own --worktree flag uses (measured in 2.1.296), and Codeman's name rules are a strict subset of its rules, so either tool recognizes the other's worktrees.
  • Backend. src/git-worktrees.ts has a pure half (name, branch and base gates, git worktree list --porcelain parsing, exclude text) and an IO half that runs git through an injectable runner: argv only, -- before path operands, the non-interactive environment, a timeout that kills the whole process group. Routes in src/web/routes/worktree-routes.ts:
    • GET /api/cases/:name/worktrees lists the case repository's worktrees, which ones Codeman manages, and the live sessions in each.
    • POST /api/cases/:name/worktrees with { name, branch?, base? } creates one. Only a new branch (-b, never -B), from a base resolved to a commit first.
    • DELETE /api/cases/:name/worktrees/:worktree[?deleteBranch=true] runs git worktree remove without --force (git's refusal on a dirty tree is the answer), refused while a live session runs inside; the branch delete is git branch -d, never -D.
  • Path safety. Only <repo>/.claude/worktrees/<valid-name> is ever created or removed. Every component is lstated and realpath-checked, so a symlinked .claude cannot redirect a create or remove. git never runs at or inside a Docker case workspace, since git worktree add runs the repository's hooks and checkout filters on the host.
  • Main checkout stays clean. /.claude/worktrees/ is appended to the repository's local .git/info/exclude (never a tracked file) when git does not ignore it already, together with the files Codeman itself writes into every workspace: the hooks file .claude/settings.local.json and the agent skill copy (<skills dir>/codeman/). Without those lines every worktree a session ran in looked modified, and git worktree remove refused it.
  • Starting sessions. No new spawn path. POST /api/quick-start takes worktree: "<name>"; POST /api/sessions takes the worktree path as workingDir. The UI launches through the Run button's own run() with a temporary run-mode swap, and tabs are named w<n>-<worktree>.
  • UI. A "Git worktrees…" row in the case picker (a Worktrees button in the phone case sheet) opens a dialog: list with Open and Remove…, a form for a new worktree, and a picker for which CLI to start. Closing a session that runs in a managed worktree offers "Kill & remove worktree". zh-CN strings included.

This branch also carries three fixes from review, each with a regression test:

  1. A session spelled through the case's symlink did not block removal. Sessions keep the workingDir they were created with, while git lists real paths, so a session in a worktree of a linked case whose folder is a symlink counted for no worktree, and DELETE removed the folder under the running agent (reproduced: 200 instead of 409). Session folders under the case path are now respelled onto the repository's real root before comparing (lexical, no filesystem call per session).
  2. A worktree whose folder was deleted by hand was a dead end. The dialog offered nothing, DELETE answered "No worktree named ...", and the name could never be reused (branch exists, then git refuses "a missing but already registered worktree"). Removal now accepts a missing managed worktree that git lists at exactly the managed path (no symlink on the way); git then only drops its record. A session can never start in one.
  3. With "Codeman Agent Skill" on, no worktree could ever be removed. The skill is injected into the session's folder, untracked, so git refused every removal. The skill copy is now excluded like the hooks file, for every skills folder the CLI registry names (read as data, no CLI id).

Testing

Unit and route tests (all against real git where it matters):

  • test/git-worktrees.test.ts: pure gates, then a real repository: create, exclude lines, symlink guards, dirty/live/locked refusals, branch -d, missing worktree removal (folder gone, locked, symlink on the way).
  • test/routes/worktree-routes.test.ts: a real repository through the routes and quick-start; Docker refusal with a runner that counts calls (zero); remote, non-repository, missing case, bad names, symlinked .claude, dirty tree, live session, multi-user ownership; plus the three review fixes (symlink-spelled session, missing folder, agent skill copies).
  • test/worktree-ui.test.ts (shipped session-ui.js in a vm and JSDOM), test/worktree-modal-i18n.test.ts (real translator over the real index.html), test/session-worktree-label.test.ts.
  • Gate: npm run typecheck, npm run lint, npm run format:check, npm run check:frontend-syntax (46 files), npm run check:public-assets (63 files) all clean; full npm test: 571 files passed, 1 skipped; 11,349 tests passed, 12 skipped, 0 failed.

Live end-to-end on an isolated instance (CODEMAN_INSTANCE=wtrev, port 5334, scratch repository with one commit; boot showed 0 sessions and no restore lines):

  • API: create api-wt 201 (excludeAdded: /.claude/worktrees/, /.claude/settings.local.json, /.claude/skills/codeman/, /.agents/skills/codeman/); ../escape 400; same name again 409; a non-repository case 400; unknown case 404. git worktree list showed [main] and [worktree-api-wt]; main git status --porcelain empty.
  • Quick-start shell with worktree: "api-wt": in the pane, pwd; git branch --show-current printed .../wtrepo/.claude/worktrees/api-wt and worktree-api-wt. DELETE while it lived: 409 "A live session is still running in worktree "api-wt"". The unified session list row carried worktreeName: "api-wt".
  • Fix 1 live: the same repository linked a second time through a symlink (wtrepo-link), a worktree sym-wt created through it, a session created with workingDir spelled through the link. DELETE: 409, the list showed sessionCount: 1, folder still there.
  • Browser (Playwright, 1440x900, DSF 1): picker action rows ["New or link a case…","Case settings…","Git worktrees…"]; ../bad showed the inline error; "ui-wt" with Claude Code then "Create & start session" opened tab w1-ui-wt with workingDir in the worktree; worktree and main status both empty with Claude's hooks file present.
  • Close Session on a dirty worktree (an untracked note): "Kill & remove worktree ui-wt" killed the session (GET 404) and kept the worktree, toast quoting git's "contains modified or untracked files" refusal. After committing the note, Remove… with "Also delete its branch": toast "Worktree ui-wt removed. Branch worktree-ui-wt kept: error: the branch 'worktree-ui-wt' is not fully merged."
  • Fix 2 live: gone-wt created, its folder deleted; the row showed "missing" with only Remove… and the note "Its folder is gone. ..."; Remove with the branch option: "Worktree gone-wt and branch worktree-gone-wt removed"; creating gone-wt again: 201.
  • Fix 3 live: skill-wt with skill copies in .claude/skills/codeman/ and .agents/skills/codeman/: worktree status empty, DELETE 200 with branchDeleted: true.
  • Three parallel creates of one name: one 201, two 409.
  • Phone (390x844, touch): case sheet shows the Worktrees button; the dialog fits (scrollWidth 390).
  • Teardown: every session deleted by exact id, server stopped, instance tmux socket and data dir removed.

Not run: npm run test:browser, test:mobile, test:perf (the Playwright run above covered the UI paths).

Reviewing the diff

About 3,400 added lines, but most of it is mechanical:

  • Tests: about 1,225 lines (test/git-worktrees.test.ts, test/routes/worktree-routes.test.ts, test/worktree-ui.test.ts, test/worktree-modal-i18n.test.ts, small edits to two existing tests).
  • Docs: about 130 lines (docs/api-reference.md, docs/architecture-invariants.md, the new wiki page docs/wiki/Git-Worktrees.md and its index entries, a short CLAUDE.md pointer).
  • i18n strings (54), CSS (246, skin variables only), dialog markup in index.html (63), route registration (3).

The roughly 1,250 lines that need real review, in this order:

  1. src/git-worktrees.ts (937 lines, about 650 of code): read the @fileoverview rules first, then resolveManagedWorktree, createCaseWorktree, removeCaseWorktree and ensureExcluded. This is the security-relevant part (paths, symlinks, argv).
  2. src/web/routes/worktree-routes.ts (about 220 lines of code): case resolution mirrors quick-start (remote, Docker, linked admin-only in multi-user mode, own case space, bounded probe), and the live-session check.
  3. The quick-start block in src/web/routes/session-routes.ts (about 25 lines of logic; the rest is resolvedCasePath renamed to workspacePath where the session's folder is meant).
  4. src/web/public/session-ui.js: the dialog and _launchInWorktree (about 400 lines); the runClaude/runShell hunks are mostly the old case lookup re-indented into an else. Then closeSession/confirmCloseSession in app.js (about 30 lines).

Risk

  • Writing to .git/info/exclude of the user's repository. Besides the worktrees folder, Codeman now hides .claude/settings.local.json and its agent skill copy from git status in the main checkout too, since one exclude file serves the main checkout and every worktree. Tracked files are unaffected, but a user who wants to commit one of those untracked files needs git add -f. The alternative (leaving them visible) makes every worktree permanently unremovable without --force, which Codeman never uses.
  • Treating every .claude/worktrees/<name> as managed, including worktrees Claude Code created itself. They get Open and Remove in the dialog. Removal is still guarded by git (dirty or locked trees are refused, and Claude Code locks the worktrees its live agents use) and by the live-session check, but a reviewer may prefer read-only rows for worktrees Codeman did not create.

Follow-ups

  • Live-session detection is lexical. A session that names a worktree through a symlink other than its case's own path (the same repository linked as two cases through different spellings) is still not seen; closing that would need a bounded realpath per session.
  • Close Session's "Kill & remove worktree" matches the session folder against the case list by path text, so a case reached through a symlink does not get the option (the dialog's Remove still works).
  • Docker and remote cases, checking out an existing branch into a worktree, a case folder below the repository top level, and an SSE event so an open dialog refreshes on another device.
  • git's multi-line refusals are cut to their fatal:/error: lines, so a continuation line can be lost.

Sessions in one case share its folder, so several agents on one repository
edit, build and commit in the same checkout and step on each other. A case
whose folder is a git repository can now keep worktrees under
<repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>),
which is Claude Code's own --worktree layout (measured in Claude Code
2.1.296) and the one Codeman's worktree pill already recognizes.

New module src/git-worktrees.ts (pure name/branch gates, worktree list
parsing, exclude text; IO through an injectable runner) and routes
GET/POST /api/cases/:name/worktrees and
DELETE /api/cases/:name/worktrees/:worktree:

- git runs as argv with -- before paths, non-interactive env, process
  group timeouts, --no-optional-locks on reads; names are gated before
  git and confirmed with git check-ref-format --branch.
- Only <repo>/.claude/worktrees/<valid-name> is created or removed; every
  path component is lstat'ed and realpath-checked, so a symlinked .claude
  cannot redirect a create or remove outside the repository.
- /.claude/worktrees/ (and the per-workspace hooks file Codeman writes,
  which otherwise blocks git worktree remove) go to the repository's
  local info/exclude, never a tracked .gitignore.
- Removal is git worktree remove WITHOUT --force, refused while a live
  session runs inside; branch delete is git branch -d, never -D.
- Docker and remote cases are refused with a 400, and git never runs for
  a case at or inside a Docker case workspace.

Starting a session reuses the existing create paths: quick-start takes
a worktree name (validated, local cases only) and runs hooks, the skill,
the model override and the session itself in the worktree;
POST /api/sessions takes the worktree as workingDir. The unified session
list names the worktree of a live session from its folder layout, so a
codex or shell session in one gets the worktree pill too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The worktree API needs a front door that works from the place cases are
already picked. The toolbar case picker gains a third action row,
"Git worktrees…" (the phone case sheet gets a Worktrees button next to
Create New Case; no header button anywhere), opening one dialog per case:

- the repository's worktrees, with the managed ones (.claude/worktrees)
  offering Open (start another session in it) and Remove…, an inline
  confirmation with "Also delete its branch" (git branch -d) whose git
  refusal is shown verbatim through textContent;
- a New worktree form (name, optional branch and start point) and a
  "Start sessions with" picker over every enabled, installed run mode,
  built from the registry catalogue as data, never an id branch.

Launching goes through run() with options.worktree and the same temporary
_runMode swap the custom-model entries use, so every mode keeps its own
path: claude and shell create through POST /api/sessions with the
worktree as workingDir, every other CLI through quick-start's worktree
field. Tabs are named w<n>-<worktree> and the Run button's mode is left
as it was.

Close Session offers "Kill & remove worktree" for a session that runs in
a Codeman-managed worktree; the removal waits for the server to confirm
the session is gone (closeSession now resolves true/false), and a dirty
tree keeps the worktree with git's reason in a toast that opens the
dialog. zh-CN strings and toast patterns for all of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Documents the worktree endpoints and quick-start's worktree field in
docs/api-reference.md, adds a Git Worktrees page to the wiki (sidebar,
Home index, a pointer from Core Concepts where sessions sharing one
case folder is explained), and records the rules that keep the feature
safe in docs/architecture-invariants.md with a short CLAUDE.md pointer:
only the managed .claude/worktrees path is ever touched, git never runs
over a Docker workspace, removal is never forced and branch delete is
-d only, and sessions start through the existing create paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…emoval

The removal guard and the per-worktree session count compared each
session's workingDir lexically against the worktree paths git lists,
which are real paths. POST /api/sessions stores workingDir as it was
spelled, so a session started in a worktree of a linked case whose
folder is a symlink (a repository on another disk, macOS /tmp, a
symlinked cases folder), named through that link, counted for no
worktree, and DELETE /api/cases/:name/worktrees/:worktree removed the
folder under the running agent (reproduced: 200 instead of 409).

Each session folder under the case path is now respelled onto the
repository's real top level before the comparison (respellUnder, pure
and lexical, so no filesystem call per session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a managed worktree's folder is deleted by hand (a user in a file
manager, an agent's rm -rf), git keeps its record and lists it as
prunable. Codeman then had no way out: the dialog showed the row as
"missing" with no action, DELETE answered "No worktree named ...", and
the name could never be used again (the branch still existed, and with
another branch git refused "a missing but already registered
worktree"). Reproduced on git 2.43; git's own `git worktree remove` on
the missing path drops the record with exit 0.

Removal (only removal, never a session start) now accepts a worktree
whose folder is gone when git lists it at exactly the managed path, as
prunable or locked, and no component on the way is a symlink or a file.
The live-session and lock refusals still apply. The dialog offers
Remove... (not Open) on such a row, with a note saying what it does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Codeman Agent Skill on (agentSkillEnabled), every session create
writes the skill into the folder the session runs in, so a session in a
worktree leaves an untracked .claude/skills/codeman/ (claude) or
.agents/skills/codeman/ (codex, gemini and the rest) behind. git then
refuses `git worktree remove` for that worktree forever, and an agent
running `git add -A` there would commit Codeman's skill into its branch
(reproduced: the worktree's status showed "?? .agents/" and
"?? .claude/").

A create now excludes <skills dir>/codeman/ next to the hooks file, for
every skills folder the registry names (capabilities.agentSkillDir,
read at call time by the route and passed in as data, so no CLI id is
branched on). Like the other lines it is only added when git does not
already ignore the path, and only to the repository's local
info/exclude.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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