feat(worktrees): create, list and remove git worktrees of a local case

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>
This commit is contained in:
Codeman maintainer committed 2026-10-14 05:16:00 +02:00
1 parent a8a8bb71a3
commit 517e12fe1d
10 files changed
+1963 -9

No files matched your search

+23
View File
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
if (value !== undefined) item[key] = value;
}
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
*/
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
const m = WORKTREE_PATH.exec(workingDir);
return m ? { repo: m[1], name: m[2] } : null;
}
/**
* Merge all source views into one list, applying precedence
* (history → lifecycle → persisted → live) and the meaningfulness floor.
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (byDir) item.lastPrompt = byDir.prompt;
}
}
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
// shell session never gets one, so its row read like any other session of the repo. The
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
if (!item.worktreeName && item.workingDir) {
const wt = worktreeFromPath(item.workingDir);
if (wt) {
item.worktreeName = wt.name;
item.worktreeRepo ??= wt.repo;
}
}
}
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.