From 517e12fe1dff358d2dc392cb1135178a82b0fb29 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 14 Oct 2026 04:42:51 +0200 Subject: [PATCH] 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 /.claude/worktrees/ on a new branch (default worktree-), 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 /.claude/worktrees/ 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) --- src/git-worktrees.ts | 870 ++++++++++++++++++++++++ src/services/unified-session-service.ts | 23 + src/web/routes/index.ts | 1 + src/web/routes/session-routes.ts | 52 +- src/web/routes/worktree-routes.ts | 258 +++++++ src/web/schemas.ts | 35 + src/web/server.ts | 2 + test/git-worktrees.test.ts | 373 ++++++++++ test/routes/worktree-routes.test.ts | 340 +++++++++ test/session-worktree-label.test.ts | 18 + 10 files changed, 1963 insertions(+), 9 deletions(-) create mode 100644 src/git-worktrees.ts create mode 100644 src/web/routes/worktree-routes.ts create mode 100644 test/git-worktrees.test.ts create mode 100644 test/routes/worktree-routes.test.ts diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts new file mode 100644 index 00000000..cf403e40 --- /dev/null +++ b/src/git-worktrees.ts @@ -0,0 +1,870 @@ +/** + * @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a + * repository-backed case keeps under `/.claude/worktrees/`, so several agents working + * on one repository each get their own working tree instead of sharing the case folder (sessions + * in one case share its folder, and two agents editing, building and committing in one checkout + * step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one + * through the existing create paths (`POST /api/quick-start` with `worktree`, or + * `POST /api/sessions` with the worktree as `workingDir`). + * + * WHERE. `/.claude/worktrees/` on a new branch `worktree-` is Claude Code's own + * `--worktree` layout (measured in Claude Code 2.1.296: path `/.claude/worktrees/`, + * branch `worktree-`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and + * Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts). + * Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a + * worktree made here is one `claude --worktree ` would also find. The main checkout must not + * show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL + * `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not + * already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its + * hooks there for every claude session, and an untracked copy in a worktree makes + * `git worktree remove` refuse forever (Claude Code itself ignores that file through the global + * excludes file, which is not guaranteed to be set up on a given machine). + * + * Split like the other git modules: a PURE half (name and branch validation, `worktree list` + * parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that + * spawns git through an injectable runner. + * + * Rules the code keeps and the tests pin: + * - git is spawned with an argv array, never a shell, with `--` before every path operand, the + * non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole + * process group, and capped output. Read-only calls add `--no-optional-locks`. + * - Names and branches are validated BEFORE they reach git (charset, length, then + * `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands + * are refused up front, since `--branch` would expand them). + * - Only `/.claude/worktrees/` is ever created or removed. Every component of + * that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the + * worktree itself, is refused) and its real path must be exactly the expected one, so a + * repository cannot redirect a create or a remove outside itself. What is left is a same-user + * race (an agent swapping a directory between the check and git), which is accepted: that + * agent already runs code as this user. + * - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or + * untracked files, and that refusal is the answer), refused while a live session runs inside + * the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged + * branch survives). + * - git never runs for a case at or inside a Docker case workspace (or whose common git dir is + * there): a container could plant a checkout filter or a hook, and `git worktree add` runs the + * repository's post-checkout hook and filters on the HOST. On a local case that is acceptable, + * since the user's agent runs code there anyway. + * - A repository that merely encloses the case and is the home folder or above (a dotfiles repo + * in `$HOME`) is not the case's repository, and a case folder below a repository's top level + * is refused: the worktrees would not be where the case is. + * + * @module git-worktrees + */ + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js'; +import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js'; + +// ─── Limits ────────────────────────────────────────────────────────────────── + +/** Claude Code's own limit for a `--worktree` name (2.1.296). */ +export const MAX_WORKTREE_NAME_LENGTH = 64; +/** Longest accepted branch name. git allows more; 200 covers every real one. */ +export const MAX_WORKTREE_BRANCH_LENGTH = 200; +/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */ +export const WORKTREE_READ_TIMEOUT_MS = 15_000; +/** + * `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and + * smudge filters (Git LFS downloads objects there), so it gets the longest budget. + */ +export const WORKTREE_ADD_TIMEOUT_MS = 180_000; +/** `git worktree remove` runs a status of the tree first, then deletes it. */ +export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000; +/** Captured stdout. `worktree list` is one short block per worktree. */ +const MAX_STDOUT_BYTES = 1_000_000; +/** Captured stderr tail. */ +const MAX_STDERR_BYTES = 8_192; +/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */ +export const MAX_WORKTREES_LISTED = 200; + +/** Path segments of the managed worktrees folder, relative to the repository root. */ +export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const; +/** The `info/exclude` line that hides the managed worktrees from the main checkout. */ +export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/'; +/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */ +export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json'; + +// ─── Pure: names ───────────────────────────────────────────────────────────── + +const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** + * Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits, + * `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the + * session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's + * own ref rules, since the default branch is derived from the name). + */ +export function worktreeNameError(name: unknown): string | null { + if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.'; + if (name.length > MAX_WORKTREE_NAME_LENGTH) + return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`; + if (!WORKTREE_NAME_RE.test(name)) + return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.'; + if (name.includes('..')) return 'A worktree name may not contain "..".'; + if (name.endsWith('.') || name.toLowerCase().endsWith('.lock')) + return 'A worktree name may not end with "." or ".lock".'; + return null; +} + +/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-`. */ +export function defaultWorktreeBranch(name: string): string { + return `worktree-${name}`; +} + +/** + * Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final + * word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing + * option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all. + */ +export function worktreeBranchError(branch: unknown): string | null { + if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.'; + if (branch.length > MAX_WORKTREE_BRANCH_LENGTH) + return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`; + if (!BRANCH_NAME_RE.test(branch)) + return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.'; + if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".'; + if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock')) + return 'A branch name may not end with "/", "." or ".lock".'; + if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".'; + return null; +} + +/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */ +export function worktreeBaseError(base: unknown): string | null { + if (typeof base !== 'string' || !isSafeGitRef(base)) + return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").'; + return null; +} + +// ─── Pure: paths ───────────────────────────────────────────────────────────── + +/** `/.claude/worktrees`. */ +export function managedWorktreesDir(repoRoot: string): string { + return join(repoRoot, ...WORKTREES_DIR_SEGMENTS); +} + +/** `/.claude/worktrees/`. The caller validates `name` first. */ +export function managedWorktreePath(repoRoot: string, name: string): string { + return join(managedWorktreesDir(repoRoot), name); +} + +/** + * The managed worktree name of `path`, or null when it is not exactly + * `/.claude/worktrees/`. Lexical: both sides must already be real paths. + */ +export function managedWorktreeName(repoRoot: string, path: string): string | null { + const dir = managedWorktreesDir(repoRoot) + sep; + const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path; + if (!clean.startsWith(dir)) return null; + const name = clean.slice(dir.length); + if (!name || name.includes(sep)) return null; + return worktreeNameError(name) === null ? name : null; +} + +/** True when `child` is `root` or inside it (lexical, on resolved paths). */ +export function isPathWithin(child: string, root: string): boolean { + const c = resolve(child); + const r = resolve(root); + return c === r || c.startsWith(r === sep ? r : r + sep); +} + +// ─── Pure: `git worktree list --porcelain` ─────────────────────────────────── + +export interface ParsedWorktree { + path: string; + /** Commit checked out (absent for a bare entry). */ + head: string | null; + /** Short branch name (`refs/heads/` stripped), null when detached or bare. */ + branch: string | null; + detached: boolean; + bare: boolean; + locked: boolean; + lockReason?: string; + prunable: boolean; + prunableReason?: string; +} + +/** + * Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when + * they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are + * raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged. + */ +export function unquoteGitPath(text: string): string { + if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text; + const body = text.slice(1, -1); + const bytes: number[] = []; + const simple: Record = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 }; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch !== '\\') { + for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b); + continue; + } + const next = body[i + 1]; + if (next !== undefined && /[0-7]/.test(next)) { + const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0]; + bytes.push(parseInt(oct, 8) & 0xff); + i += oct.length; + } else if (next !== undefined && next in simple) { + bytes.push(simple[next]); + i += 1; + } else { + bytes.push(92); + } + } + return Buffer.from(bytes).toString('utf-8'); +} + +/** + * Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu + * 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths + * are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII + * repository paths readable. + */ +export function parseWorktreeList(text: string): ParsedWorktree[] { + const out: ParsedWorktree[] = []; + let cur: ParsedWorktree | null = null; + const flush = () => { + if (cur) out.push(cur); + cur = null; + }; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!line) { + flush(); + continue; + } + const space = line.indexOf(' '); + const key = space === -1 ? line : line.slice(0, space); + const value = space === -1 ? '' : line.slice(space + 1); + if (key === 'worktree') { + flush(); + cur = { + path: unquoteGitPath(value), + head: null, + branch: null, + detached: false, + bare: false, + locked: false, + prunable: false, + }; + continue; + } + if (!cur) continue; + const entry: ParsedWorktree = cur; + if (key === 'HEAD') entry.head = value || null; + else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null; + else if (key === 'detached') entry.detached = true; + else if (key === 'bare') entry.bare = true; + else if (key === 'locked') { + entry.locked = true; + if (value) entry.lockReason = value; + } else if (key === 'prunable') { + entry.prunable = true; + if (value) entry.prunableReason = value; + } + } + flush(); + return out; +} + +// ─── Pure: exclude file ────────────────────────────────────────────────────── + +/** + * What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it: + * the text ('' when nothing is missing) and the lines it adds. A file that does not end in a + * newline gets one first, so an appended pattern never fuses with the user's last line. + */ +export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } { + const present = new Set(existing.split(/\r?\n/).map((l) => l.trim())); + const added = lines.filter((l) => !present.has(l)); + if (added.length === 0) return { text: '', added }; + const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; + return { + text: `${lead}# Added by Codeman: git worktrees and the per-workspace hooks file stay local\n${added.join('\n')}\n`, + added, + }; +} + +// ─── Pure: git's answers ───────────────────────────────────────────────────── + +/** + * The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines + * dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only. + */ +export function gitRefusalText(stderr: string): string { + const clean = sanitizeGitOutput(stderr, 2000); + const lines = clean + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !/^hint:/i.test(l)); + const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l)); + const text = (diagnostic.length ? diagnostic : lines).join('\n'); + return text.length > 1000 ? `${text.slice(0, 1000)}…` : text; +} + +// ─── IO: git runner ────────────────────────────────────────────────────────── + +export interface WorktreeGitResult { + code: number | null; + stdout: string; + stderr: string; + timedOut: boolean; + spawnError?: string; +} + +/** Runs `git ` in `cwd`; never throws. Injected so tests can refuse to run git at all. */ +export type WorktreeGitRunner = ( + cwd: string, + args: string[], + opts?: { timeoutMs?: number } +) => Promise; + +/** + * Global options for a call that only reads: no optional index refresh, no filesystem monitor, + * no path quoting of non-ASCII names. Must precede the subcommand. + */ +const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false']; + +/** + * The production runner: argv only (no shell), the non-interactive environment, captured output + * caps, and a timeout that signals the whole process group (`detached: true`), because + * `git worktree add` fans out into checkout children, hooks and filters that a signal to the + * parent alone would leave running. + */ +export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) => + new Promise((resolveRun) => { + let child: ReturnType; + try { + child = spawn('git', args, { + cwd, + env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + } catch (err) { + resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) }); + return; + } + let stdout = ''; + let stdoutBytes = 0; + let stderr = ''; + let timedOut = false; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + const killTree = (signal: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + /* already gone */ + } + } + }; + const timer = setTimeout(() => { + timedOut = true; + killTree('SIGTERM'); + killTimer = setTimeout(() => killTree('SIGKILL'), 3_000); + }, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS); + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES); + }); + const finish = (result: WorktreeGitResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + resolveRun(result); + }; + child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) })); + child.on('close', (code) => finish({ code, stdout, stderr, timedOut })); + }); + +// ─── IO: results ───────────────────────────────────────────────────────────── + +/** Failure codes, named after the API error codes the routes map them to. */ +export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED'; + +export interface WorktreeFailure { + ok: false; + code: WorktreeFailureCode; + message: string; + /** git's own words, when git refused (sanitized; render as text). */ + gitMessage?: string; +} + +const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({ + ok: false, + code, + message, + ...(gitMessage ? { gitMessage } : {}), +}); + +function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure { + if (run.spawnError && /enoent/i.test(run.spawnError)) + return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).'); + if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`); + const text = gitRefusalText(run.stderr); + return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text); +} + +// ─── IO: the case's repository ─────────────────────────────────────────────── + +export interface CaseRepository { + /** The repository's top level, which is the case folder (real path). */ + repoRoot: string; + /** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */ + commonDir: string; +} + +export interface WorktreeOptions { + git?: WorktreeGitRunner; + /** Host paths of every Docker case workspace: git never runs at or inside one. */ + dockerWorkspaces?: string[]; + /** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */ + home?: string; +} + +const realOr = async (p: string): Promise => { + try { + return await fs.realpath(p); + } catch { + return resolve(p); + } +}; + +const DOCKER_REFUSAL = + 'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.'; + +/** + * The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs + * git when the folder (or the repository's common git dir) is at or inside a Docker case workspace. + */ +export async function resolveCaseRepository( + casePath: string, + opts: WorktreeOptions = {} +): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr)); + if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL); + + const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + if (run.code !== 0 || run.spawnError || run.timedOut) { + if (/must be run in a work tree/i.test(run.stderr)) { + return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.'); + } + if (/not a git repository/i.test(run.stderr)) { + return fail('INVALID_INPUT', 'This case folder is not a git repository.'); + } + return gitFailure(run, 'reading the repository'); + } + const [top, common] = run.stdout.split('\n').map((l) => l.trim()); + if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.'); + const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]); + const commonDir = await realOr(resolve(casePath, common)); + if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL); + if (repoRoot !== caseReal) { + if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) { + return fail('INVALID_INPUT', 'This case folder is not a git repository.'); + } + return fail( + 'INVALID_INPUT', + `Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.` + ); + } + return { ok: true, repo: { repoRoot, commonDir } }; +} + +// ─── IO: listing ───────────────────────────────────────────────────────────── + +export interface CaseWorktree extends ParsedWorktree { + /** The repository's main checkout (git lists it first). */ + main: boolean; + /** The case folder itself (the main checkout, or a linked worktree linked as the case). */ + isCase: boolean; + /** Under `/.claude/worktrees/`: Codeman may start sessions in it and remove it. */ + managed: boolean; + /** The managed name, when `managed`. */ + name?: string; +} + +export async function listCaseWorktrees( + repo: CaseRepository, + opts: WorktreeOptions = {} +): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees'); + const parsed = parseWorktreeList(run.stdout); + const truncated = parsed.length > MAX_WORKTREES_LISTED; + const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => { + const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path); + return { + ...wt, + main: index === 0, + isCase: wt.path === repo.repoRoot, + managed: name !== null, + ...(name ? { name } : {}), + }; + }); + return { ok: true, worktrees, truncated }; +} + +// ─── IO: path guards ───────────────────────────────────────────────────────── + +type DirState = 'dir' | 'absent' | 'symlink' | 'other'; + +async function lstatState(path: string): Promise { + try { + const st = await fs.lstat(path); + if (st.isSymbolicLink()) return 'symlink'; + return st.isDirectory() ? 'dir' : 'other'; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return 'absent'; + throw err; + } +} + +/** + * Make sure `/.claude` and `/.claude/worktrees` are real directories (created when + * absent, refused when a symlink or a file) whose real path is exactly the lexical one. + */ +async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> { + let cur = repoRoot; + for (const segment of WORKTREES_DIR_SEGMENTS) { + cur = join(cur, segment); + const state = await lstatState(cur); + if (state === 'symlink') { + return fail( + 'INVALID_INPUT', + `${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.` + ); + } + if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`); + if (state === 'absent') { + try { + await fs.mkdir(cur); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`); + } + if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`); + } + } + } + if ((await realOr(cur)) !== cur) { + return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`); + } + return { ok: true, dir: cur }; +} + +/** + * The managed worktree `name` of `repo` as it is on disk now: every component a real directory + * (no symlinks), the real path the expected one, and git listing it as a worktree of this + * repository. Used by removal and by starting a session in it. + */ +export async function resolveManagedWorktree( + repo: CaseRepository, + name: string, + opts: WorktreeOptions = {} +): Promise<{ ok: true; path: string; worktree: CaseWorktree } | WorktreeFailure> { + const nameError = worktreeNameError(name); + if (nameError) return fail('INVALID_INPUT', nameError); + const target = managedWorktreePath(repo.repoRoot, name); + let cur = repo.repoRoot; + for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) { + cur = join(cur, segment); + const state = await lstatState(cur); + if (state === 'absent') return fail('NOT_FOUND', `No worktree named "${name}" in this case.`); + if (state === 'symlink') { + return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`); + } + if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`); + } + if ((await realOr(target)) !== target) { + return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`); + } + const listed = await listCaseWorktrees(repo, opts); + if (!listed.ok) return listed; + for (const wt of listed.worktrees) { + if (wt.main) continue; + if (wt.path === target || (await realOr(wt.path)) === target) { + return { ok: true, path: target, worktree: { ...wt, managed: true, name } }; + } + } + return fail( + 'NOT_FOUND', + `${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).` + ); +} + +// ─── IO: excludes ──────────────────────────────────────────────────────────── + +/** + * Append each of `lines` that git does not already ignore to `/info/exclude`. Returns + * the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never + * written through). + */ +async function ensureExcluded( + repo: CaseRepository, + probes: Array<{ line: string; probe: string }>, + git: WorktreeGitRunner +): Promise<{ added: string[]; warning?: string }> { + const needed: string[] = []; + for (const { line, probe } of probes) { + const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + // 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an + // extra exclude line is harmless and a missing one leaves the worktrees untracked. + if (run.code !== 0) needed.push(line); + } + if (needed.length === 0) return { added: [] }; + const infoDir = join(repo.commonDir, 'info'); + const excludePath = join(infoDir, 'exclude'); + try { + const infoState = await lstatState(infoDir); + if (infoState === 'absent') await fs.mkdir(infoDir); + else if (infoState !== 'dir') + return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` }; + const fileState = await lstatState(excludePath); + if (fileState === 'symlink' || fileState === 'dir') { + return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` }; + } + const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8'); + const { text, added } = excludeAppendText(existing, needed); + if (text) await fs.appendFile(excludePath, text, { mode: 0o644 }); + return { added }; + } catch (err) { + return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` }; + } +} + +// ─── IO: one operation per repository at a time ────────────────────────────── + +const repoLocks = new Map>(); + +/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */ +async function withRepoLock(repoRoot: string, fn: () => Promise): Promise { + const prev = repoLocks.get(repoRoot) ?? Promise.resolve(); + const next = prev.then(fn, fn); + const tail = next.catch(() => undefined); + repoLocks.set(repoRoot, tail); + try { + return await next; + } finally { + if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot); + } +} + +// ─── IO: create ────────────────────────────────────────────────────────────── + +export interface CreateWorktreeRequest { + name: string; + /** New branch; default `worktree-`. */ + branch?: string; + /** Start point; default `HEAD` (the case's current commit). */ + base?: string; +} + +export interface CreatedWorktree { + name: string; + path: string; + branch: string; + base: string; + /** The commit the new branch starts at. */ + head: string; + /** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */ + excludeAdded: string[]; + warnings: string[]; +} + +export async function createCaseWorktree( + repo: CaseRepository, + req: CreateWorktreeRequest, + opts: WorktreeOptions = {} +): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + const nameError = worktreeNameError(req.name); + if (nameError) return fail('INVALID_INPUT', nameError); + const branch = req.branch || defaultWorktreeBranch(req.name); + const branchError = worktreeBranchError(branch); + if (branchError) return fail('INVALID_INPUT', branchError); + const base = req.base || 'HEAD'; + const baseError = worktreeBaseError(base); + if (baseError) return fail('INVALID_INPUT', baseError); + + return withRepoLock(repo.repoRoot, async () => { + // A name already taken says so in those words, before the branch check would report the + // derived branch instead. Rechecked after the folders are prepared, below. + try { + if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') { + return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`); + } + } catch { + /* unreadable: the check after the folders are prepared reports it */ + } + // git's own word on the branch name. The output must be the input: `--branch` expands + // shorthands, and anything it rewrote is not the name the user typed. + const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) { + return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr)); + } + const existingBranch = await git( + repo.repoRoot, + [...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], + { timeoutMs: WORKTREE_READ_TIMEOUT_MS } + ); + if (existingBranch.code === 0) { + return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`); + } + const resolved = await git( + repo.repoRoot, + // No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a + // leading `-`, so it cannot be read as an option. + [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`], + { timeoutMs: WORKTREE_READ_TIMEOUT_MS } + ); + const head = resolved.stdout.trim(); + if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) { + return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`); + } + + const prepared = await prepareWorktreesDir(repo.repoRoot); + if (!prepared.ok) return prepared; + const target = join(prepared.dir, req.name); + try { + if ((await lstatState(target)) !== 'absent') { + return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`); + } + } catch (err) { + return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`); + } + + const excluded = await ensureExcluded( + repo, + [ + { line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` }, + { line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' }, + ], + git + ); + + // The resolved commit, not the ref text, after `--`: nothing here can be read as an option, + // and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main. + const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], { + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + }); + if (add.code !== 0 || add.spawnError || add.timedOut) { + const text = gitRefusalText(add.stderr); + if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text); + return gitFailure(add, 'creating the worktree'); + } + return { + ok: true, + worktree: { + name: req.name, + path: target, + branch, + base, + head, + excludeAdded: excluded.added, + warnings: excluded.warning ? [excluded.warning] : [], + }, + }; + }); +} + +// ─── IO: remove ────────────────────────────────────────────────────────────── + +export interface RemoveWorktreeRequest { + name: string; + /** Also `git branch -d` the worktree's branch (never `-D`). */ + deleteBranch?: boolean; + /** True when a live session runs at or inside `path`: the removal is refused. */ + isInUse: (path: string) => boolean | Promise; +} + +export interface RemovedWorktree { + name: string; + path: string; + branch: string | null; + branchDeleted: boolean; + /** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */ + branchError?: string; +} + +export async function removeCaseWorktree( + repo: CaseRepository, + req: RemoveWorktreeRequest, + opts: WorktreeOptions = {} +): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + return withRepoLock(repo.repoRoot, async () => { + const found = await resolveManagedWorktree(repo, req.name, opts); + if (!found.ok) return found; + if (await req.isInUse(found.path)) { + return fail( + 'CONFLICT', + `A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.` + ); + } + if (found.worktree.locked) { + return fail( + 'CONFLICT', + `Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.` + ); + } + const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], { + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + }); + if (removed.code !== 0 || removed.spawnError || removed.timedOut) { + if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree'); + const text = gitRefusalText(removed.stderr); + // git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is + // the answer, in git's words. + return fail( + 'CONFLICT', + text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.', + text + ); + } + const branch = found.worktree.branch; + let branchDeleted = false; + let branchError: string | undefined; + if (req.deleteBranch && branch) { + const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS }); + if (del.code === 0) branchDeleted = true; + else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.'; + } + return { + ok: true, + removed: { + name: req.name, + path: found.path, + branch, + branchDeleted, + ...(branchError ? { branchError } : {}), + }, + }; + }); +} diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 9bd447b3..5fb9b514 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -167,6 +167,18 @@ function overwrite( if (value !== undefined) item[key] = value; } +/** `/.claude/worktrees/`: 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 + * `/.claude/worktrees/` (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. diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 7d9be0b1..aa1d721f 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -17,6 +17,7 @@ export { registerReadMyMindRoutes } from './readmymind-routes.js'; export { registerGitStatusRoutes } from './git-status-routes.js'; export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js'; export { registerCaseRoutes } from './case-routes.js'; +export { registerWorktreeRoutes } from './worktree-routes.js'; export { registerSessionRoutes } from './session-routes.js'; export { registerRespawnRoutes } from './respawn-routes.js'; export { registerRalphRoutes } from './ralph-routes.js'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc553..82a6a7b7 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -181,6 +181,7 @@ import { readDockerHosts, toSessionDocker, } from '../../docker-hosts.js'; +import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js'; import { LRUMap } from '../../utils/lru-map.js'; import { describeUnknownPath, probePathKind } from '../../utils/index.js'; import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; @@ -3675,6 +3676,7 @@ export function registerSessionRoutes( agentOrigin, customModel, tabGroupId, + worktree, } = parseBody(QuickStartSchema, req.body); // Resolved ONCE here: the same value labels a case directory this request creates @@ -3708,6 +3710,16 @@ export function registerSessionRoutes( : (await readDockerCases(CODEMAN_CONFIG_DIR)).find( (item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner) ); + // Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a + // container is touched, since neither branch below could honour it. + if (worktree && (remoteCase || dockerCase)) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + remoteCase + ? 'worktree is not available for remote (SSH) cases: the repository is on another machine' + : 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write' + ); + } if (remoteCase) { const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); @@ -3949,6 +3961,27 @@ export function registerSessionRoutes( ); } + // Where the session runs: the case folder, or one of its git worktrees. A worktree is only + // ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not + // exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed + // one, every path component a real directory (src/git-worktrees.ts). + let workspacePath = resolvedCasePath; + if (worktree) { + if (localCaseState === 'absent') { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`); + } + if (localCaseState !== 'directory') { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder'); + } + const caseRepo = await resolveCaseRepository(resolvedCasePath, { + dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean), + }); + if (!caseRepo.ok) return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message); + const managed = await resolveManagedWorktree(caseRepo.repo, worktree); + if (!managed.ok) return createErrorResponse(ApiErrorCode[managed.code], managed.message); + workspacePath = managed.path; + } + // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote, // non-docker cases — docker workspaces are scaffolded in their own block below) if (localCaseState === 'absent') { @@ -4007,9 +4040,9 @@ export function registerSessionRoutes( // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that // doesn't exist on the local filesystem. if (getCli(mode)?.capabilities.hooks === 'always') { - await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled()); + await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled()); } else { - await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); + await refreshStaleCodemanHooks(workspacePath).catch(() => {}); } } @@ -4021,7 +4054,7 @@ export function registerSessionRoutes( // hostWorkspacePath is a real host dir and the skill crosses the bind mount. const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities); if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) { - await injectAgentSkill(resolvedCasePath, quickStartSkillDir); + await injectAgentSkill(workspacePath, quickStartSkillDir); } // Docker cases: the workspace is a REAL host dir bind-mounted into the container. @@ -4054,14 +4087,14 @@ export function registerSessionRoutes( // docker — the docker workspace is a real host dir, so the settings file crosses // the bind mount and the in-container claude reads it). Remote was rejected above. if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) { - await updateCaseModel(resolvedCasePath, modelOverride || null); + await updateCaseModel(workspacePath, modelOverride || null); } // Strip stale disk entries for keys this request is actively setting (Claude only — // see POST /api/sessions for full rationale). // Same chain, same replacement as the create path above: byte-identical, drift-proof. if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) { - await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides)); + await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides)); } // Create a new session with the case as working directory @@ -4117,7 +4150,7 @@ export function registerSessionRoutes( ); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides); - const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig); + const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig); // Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md) // rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom- @@ -4223,7 +4256,7 @@ export function registerSessionRoutes( customModel.modelId, qsCustomModelSessionId, cmContextLength, - resolvedCasePath + workspacePath ); if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); @@ -4264,7 +4297,7 @@ export function registerSessionRoutes( const qsSessionOptions: ConstructorParameters[0] = { id: qsCustomModelSessionId, - workingDir: resolvedCasePath, + workingDir: workspacePath, name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '', mux: ctx.mux, useMux: true, @@ -4322,7 +4355,7 @@ export function registerSessionRoutes( // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // so the initial state already has the phrase configured (only if globally enabled) if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) { - autoConfigureRalph(session, resolvedCasePath, ctx); + autoConfigureRalph(session, workspacePath, ctx); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart @@ -4414,6 +4447,7 @@ export function registerSessionRoutes( sessionId: session.id, casePath: resolvedCasePath, caseName, + ...(worktree ? { worktree, workingDir: workspacePath } : {}), ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}), ...(tabGroupId ? { tabLayout } : {}), }; diff --git a/src/web/routes/worktree-routes.ts b/src/web/routes/worktree-routes.ts new file mode 100644 index 00000000..b38ec46d --- /dev/null +++ b/src/web/routes/worktree-routes.ts @@ -0,0 +1,258 @@ +/** + * @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules): + * + * GET /api/cases/:name/worktrees the case repository's worktrees, which of them + * Codeman manages, and the live sessions in each + * POST /api/cases/:name/worktrees create `/.claude/worktrees/` on a + * new branch (`{ name, branch?, base? }`) + * DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused + * while a live session runs inside; optional + * `?deleteBranch=true` is `git branch -d` + * + * A session starts in a worktree through the EXISTING create paths, never a new spawn path here: + * `POST /api/quick-start` with `worktree: ` (validated against this module's rules), or + * `POST /api/sessions` with the worktree path as `workingDir`. + * + * The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused + * with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder + * is on another machine); a linked case resolves for admins only in multi-user mode; anything else + * is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed + * BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404. + * + * Statuses are set explicitly from the error code, so the route answers the same with or without + * the server's envelope hook. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js'; +import { readDockerCases } from '../../docker-hosts.js'; +import { readRemoteCases } from '../../remote-hosts.js'; +import { dataPath, getDataDir } from '../../config/instance.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { describeUnknownPath, probePathKind } from '../../utils/index.js'; +import { + createCaseWorktree, + isPathWithin, + listCaseWorktrees, + managedWorktreesDir, + removeCaseWorktree, + resolveCaseRepository, + worktreeNameError, + type CaseRepository, + type CaseWorktree, + type CreatedWorktree, + type RemovedWorktree, + type WorktreeFailure, + type WorktreeGitRunner, +} from '../../git-worktrees.js'; +import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js'; +import { + canAccessOwned, + getAuthUser, + isAdmin, + isWorkingDirAllowed, + parseBody, + readJsonConfig, + resolveCasesDir, + validatePathWithinBase, +} from '../route-helpers.js'; +import type { SessionPort } from '../ports/index.js'; + +const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; + +/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */ +const defaultDockerWorkspaces = async (): Promise => + (await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean); + +/** A live session in a worktree, as the list reports it to a caller who may see it. */ +export interface WorktreeSessionRef { + id: string; + name: string; +} + +/** One row of `GET /api/cases/:name/worktrees`. */ +export interface CaseWorktreeInfo extends CaseWorktree { + /** Live sessions running in this worktree (deepest worktree wins), all owners. */ + sessionCount: number; + /** The ones the caller may see (all of them outside multi-user mode). */ + sessions: WorktreeSessionRef[]; +} + +export interface CaseWorktreeList { + caseName: string; + repoRoot: string; + /** `/.claude/worktrees`, where Codeman creates and removes worktrees. */ + worktreesDir: string; + worktrees: CaseWorktreeInfo[]; + truncated: boolean; +} + +type Fail = ApiResponse; + +function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail { + reply.code(httpStatusForErrorCode(code)); + return createErrorResponse(code, message); +} + +function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail { + return failWith(reply, ApiErrorCode[failure.code], failure.message); +} + +/** + * The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's + * resolution (remote, then Docker, then linked, then the caller's case space) so a name means + * the same case here as it does to the Run button. + */ +async function resolveLocalCaseFolder( + req: FastifyRequest, + name: string +): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> { + if (!SAFE_CASE_NAME.test(name)) { + return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' }; + } + const user = getAuthUser(req); + const configDir = getDataDir(); + // Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker + // case falls through to the caller's own local case instead of shadowing it. + if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) { + return { + ok: false, + code: ApiErrorCode.INVALID_INPUT, + message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.', + }; + } + if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) { + return { + ok: false, + code: ApiErrorCode.INVALID_INPUT, + message: + 'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.', + }; + } + const linkedCases = await readJsonConfig>(dataPath('linked-cases.json'), 'linked cases', {}); + // Linked cases carry no owner: only admins resolve them in multi-user mode. + const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined; + const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user)); + if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' }; + if (!isWorkingDirAllowed(user, casePath)) { + return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' }; + } + const kind = await probePathKind(casePath, { pastCap: true }); + if (kind === 'unknown') { + return { + ok: false, + code: ApiErrorCode.OPERATION_FAILED, + message: describeUnknownPath('Case folder', casePath, { pastCap: true }), + }; + } + if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` }; + if (kind !== 'directory') + return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' }; + return { ok: true, casePath }; +} + +/** Live local sessions, with the folder each runs in. Remote sessions have no local folder. */ +function liveLocalSessions(ctx: SessionPort): Array<{ id: string; name: string; owner?: string; dir: string }> { + const out: Array<{ id: string; name: string; owner?: string; dir: string }> = []; + for (const session of ctx.sessions.values()) { + if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue; + out.push({ id: session.id, name: session.name, owner: session.owner, dir: session.workingDir }); + } + return out; +} + +export function registerWorktreeRoutes( + app: FastifyInstance, + ctx: SessionPort, + deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise } = {} +): void { + const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces; + + /** Case folder → its repository, or the refusal already rendered. */ + async function caseRepository( + req: FastifyRequest, + reply: FastifyReply, + name: string + ): Promise<{ ok: true; repo: CaseRepository } | { ok: false; fail: Fail }> { + const folder = await resolveLocalCaseFolder(req, name); + if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) }; + const repo = await resolveCaseRepository(folder.casePath, { + git: deps.git, + dockerWorkspaces: await dockerWorkspaces(), + }); + if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) }; + return { ok: true, repo: repo.repo }; + } + + app.get('/api/cases/:name/worktrees', async (req, reply): Promise> => { + const { name } = req.params as { name: string }; + const resolved = await caseRepository(req, reply, name); + if (!resolved.ok) return resolved.fail; + const { repo } = resolved; + const listed = await listCaseWorktrees(repo, { git: deps.git }); + if (!listed.ok) return failFrom(reply, listed); + + // Each live session belongs to the DEEPEST worktree holding its folder, so a session in a + // managed worktree is not also counted against the main checkout that contains it. + const user = getAuthUser(req); + const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] })); + for (const session of liveLocalSessions(ctx)) { + let best: CaseWorktreeInfo | undefined; + for (const row of rows) { + if (!isPathWithin(session.dir, row.path)) continue; + if (!best || row.path.length > best.path.length) best = row; + } + if (!best) continue; + best.sessionCount += 1; + if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name }); + } + return { + success: true, + data: { + caseName: name, + repoRoot: repo.repoRoot, + worktreesDir: managedWorktreesDir(repo.repoRoot), + worktrees: rows, + truncated: listed.truncated, + }, + }; + }); + + app.post('/api/cases/:name/worktrees', async (req, reply): Promise> => { + const { name } = req.params as { name: string }; + const body = parseBody(CreateWorktreeSchema, req.body); + const nameError = worktreeNameError(body.name); + if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError); + const resolved = await caseRepository(req, reply, name); + if (!resolved.ok) return resolved.fail; + const created = await createCaseWorktree(resolved.repo, body, { git: deps.git }); + if (!created.ok) return failFrom(reply, created); + reply.code(201); + return { success: true, data: { worktree: created.worktree } }; + }); + + app.delete( + '/api/cases/:name/worktrees/:worktree', + async (req, reply): Promise> => { + const { name, worktree } = req.params as { name: string; worktree: string }; + const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {}); + const nameError = worktreeNameError(worktree); + if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError); + const resolved = await caseRepository(req, reply, name); + if (!resolved.ok) return resolved.fail; + const removed = await removeCaseWorktree( + resolved.repo, + { + name: worktree, + deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1', + // Any owner's session blocks: removing the folder under a running agent is the damage + // this refusal exists to prevent, whoever started it. + isInUse: (path) => liveLocalSessions(ctx).some((s) => isPathWithin(s.dir, path)), + }, + { git: deps.git } + ); + if (!removed.ok) return failFrom(reply, removed); + return { success: true, data: { removed: removed.removed } }; + } + ); +} diff --git a/src/web/schemas.ts b/src/web/schemas.ts index f40a2a16..48281ca6 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -759,6 +759,34 @@ export const ClonePreflightSchema = z.object({ repository: z.string().min(1).max(2048), }); +// ========== Git worktrees of a case (src/git-worktrees.ts) ========== + +/** + * A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or + * digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`, + * no trailing `.lock`) are `worktreeNameError()`, which every route applies after this. + */ +const worktreeNameSchema = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".'); + +/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */ +export const CreateWorktreeSchema = z.object({ + name: worktreeNameSchema, + /** New branch name; default `worktree-`, Claude Code's convention. */ + branch: z.string().min(1).max(200).optional(), + /** Start point (branch, tag or commit); default the case's current HEAD. */ + base: z.string().min(1).max(200).optional(), +}); + +/** Query of DELETE /api/cases/:name/worktrees/:worktree. */ +export const RemoveWorktreeQuerySchema = z.object({ + /** Also `git branch -d` the worktree's branch (never `-D`). */ + deleteBranch: z.enum(['true', 'false', '1', '0']).optional(), +}); + const RemoteCommandOverridesSchema = z .object({ shell: z.string().min(1).max(300).optional(), @@ -1139,6 +1167,13 @@ export const QuickStartSchema = z.object({ parentSessionId: parentSessionIdSchema, /** Tab group to place the new session in: see tabGroupIdSchema. */ tabGroupId: tabGroupIdSchema, + /** + * Start the session in this git worktree of the case (`/.claude/worktrees/`, + * created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases + * only: refused (400) for remote and Docker cases. The worktree must be a registered worktree + * of the case's repository; it is never created here. + */ + worktree: worktreeNameSchema.optional(), /** Model override written to /.claude/settings.local.json (e.g. "opus[1m]"). * Empty string clears. Applied for local AND docker cases (the docker workspace is * a real host dir, so the settings file crosses the bind mount); rejected for diff --git a/src/web/server.ts b/src/web/server.ts index 2b11b4b3..a34d8541 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -191,6 +191,7 @@ import { registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, + registerWorktreeRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, @@ -1153,6 +1154,7 @@ export class WebServer extends EventEmitter { registerStatusTelemetryRoutes(this.app, ctx); registerSystemRoutes(this.app, ctx); registerCaseRoutes(this.app, ctx); + registerWorktreeRoutes(this.app, ctx); // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); diff --git a/test/git-worktrees.test.ts b/test/git-worktrees.test.ts new file mode 100644 index 00000000..cce98d83 --- /dev/null +++ b/test/git-worktrees.test.ts @@ -0,0 +1,373 @@ +/** + * @fileoverview src/git-worktrees.ts: the pure rules (worktree and branch names, `worktree list` + * parsing, the managed-path rule, exclude text) without git, then the IO half against a REAL temp + * repository (create, list, exclude, symlink guards, remove without --force, `branch -d`), plus an + * injected runner proving the Docker refusal never spawns git at all. Port: N/A. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + createCaseWorktree, + defaultWorktreeBranch, + excludeAppendText, + gitRefusalText, + isPathWithin, + listCaseWorktrees, + managedWorktreeName, + parseWorktreeList, + removeCaseWorktree, + resolveCaseRepository, + resolveManagedWorktree, + unquoteGitPath, + worktreeBaseError, + worktreeBranchError, + worktreeNameError, + type CaseRepository, + type WorktreeGitRunner, +} from '../src/git-worktrees.js'; + +const ENV = { + ...process.env, + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + +describe('worktreeNameError', () => { + it.each(['fix-login', 'a', 'v1.2', 'under_score', 'A9', 'x'.repeat(64)])('accepts %s', (name) => { + expect(worktreeNameError(name)).toBeNull(); + }); + it.each([ + ['', 'required'], + ['x'.repeat(65), 'at most 64'], + ['-flag', 'start with a letter'], + ['.hidden', 'start with a letter'], + ['a/b', 'only letters'], + ['a b', 'only letters'], + ['a..b', '".."'], + ['name.', 'end with'], + ['name.lock', 'end with'], + ['semi;colon', 'only letters'], + ['ünï', 'only letters'], + ])('refuses %j', (name, why) => { + expect(worktreeNameError(name)).toContain(why); + }); + it('refuses a non-string', () => { + expect(worktreeNameError(undefined)).not.toBeNull(); + expect(worktreeNameError(42)).not.toBeNull(); + }); +}); + +describe('branch and base gates', () => { + it('derives Claude Code’s default branch name', () => { + expect(defaultWorktreeBranch('fix-login')).toBe('worktree-fix-login'); + }); + it.each(['main', 'feat/login', 'worktree-a.b', 'user/x_y-z'])('accepts branch %s', (b) => { + expect(worktreeBranchError(b)).toBeNull(); + }); + it.each(['-D', '@{-1}', 'a..b', 'a//b', 'trail/', 'x.lock', 'feat/.hidden', 'sp ace', 'a~1', 'a^', 'a:b'])( + 'refuses branch %j', + (b) => { + expect(worktreeBranchError(b)).not.toBeNull(); + } + ); + it('gates the base like Clone Repo’s ref', () => { + expect(worktreeBaseError('HEAD')).toBeNull(); + expect(worktreeBaseError('origin/main')).toBeNull(); + expect(worktreeBaseError('--upload-pack=x')).not.toBeNull(); + expect(worktreeBaseError('HEAD@{1}')).not.toBeNull(); + }); +}); + +describe('paths', () => { + it('names only /.claude/worktrees/', () => { + expect(managedWorktreeName('/r', '/r/.claude/worktrees/a')).toBe('a'); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/a/')).toBe('a'); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/a/sub')).toBeNull(); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/')).toBeNull(); + expect(managedWorktreeName('/r', '/r/../x/.claude/worktrees/a')).toBeNull(); + expect(managedWorktreeName('/r', '/other/.claude/worktrees/a')).toBeNull(); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/-x')).toBeNull(); + }); + it('isPathWithin is a segment match, not a string prefix', () => { + expect(isPathWithin('/r/.claude/worktrees/a', '/r/.claude/worktrees/a')).toBe(true); + expect(isPathWithin('/r/.claude/worktrees/a/src', '/r/.claude/worktrees/a')).toBe(true); + expect(isPathWithin('/r/.claude/worktrees/ab', '/r/.claude/worktrees/a')).toBe(false); + }); +}); + +describe('parseWorktreeList', () => { + it('parses main, branch, detached, locked and prunable records', () => { + const text = [ + 'worktree /repo', + 'HEAD 1111111111111111111111111111111111111111', + 'branch refs/heads/main', + '', + 'worktree /repo/.claude/worktrees/a', + 'HEAD 2222222222222222222222222222222222222222', + 'branch refs/heads/worktree-a', + 'locked busy agent', + '', + 'worktree /elsewhere/b', + 'HEAD 3333333333333333333333333333333333333333', + 'detached', + 'prunable gitdir file points to non-existent location', + '', + ].join('\n'); + const list = parseWorktreeList(text); + expect(list).toHaveLength(3); + expect(list[0]).toMatchObject({ path: '/repo', branch: 'main', detached: false, locked: false }); + expect(list[1]).toMatchObject({ branch: 'worktree-a', locked: true, lockReason: 'busy agent' }); + expect(list[2]).toMatchObject({ branch: null, detached: true, prunable: true }); + }); + it('reads a bare record and a record with no trailing blank line', () => { + const list = parseWorktreeList('worktree /bare.git\nbare\n\nworktree /w\nHEAD abc\nbranch refs/heads/x'); + expect(list[0]).toMatchObject({ bare: true, head: null }); + expect(list[1]).toMatchObject({ path: '/w', branch: 'x' }); + }); + it('unquotes a C-quoted path', () => { + expect(parseWorktreeList('worktree "/r/a\\"b\\303\\251"\nHEAD x\n')[0].path).toBe('/r/a"bé'); + }); +}); + +describe('unquoteGitPath', () => { + it('leaves plain text alone and decodes escapes', () => { + expect(unquoteGitPath('/plain path')).toBe('/plain path'); + expect(unquoteGitPath('"/a\\tb\\\\c"')).toBe('/a\tb\\c'); + expect(unquoteGitPath('"/\\344\\275\\240"')).toBe('/你'); + }); +}); + +describe('excludeAppendText', () => { + it('adds only what is missing, after a newline the file lacked', () => { + const r = excludeAppendText('*.log', ['/.claude/worktrees/', '/.claude/settings.local.json']); + expect(r.added).toEqual(['/.claude/worktrees/', '/.claude/settings.local.json']); + expect(r.text.startsWith('\n# Added by Codeman')).toBe(true); + expect(r.text.endsWith('/.claude/settings.local.json\n')).toBe(true); + }); + it('is a no-op when every line is present', () => { + expect(excludeAppendText('/.claude/worktrees/\n', ['/.claude/worktrees/'])).toEqual({ text: '', added: [] }); + }); +}); + +describe('gitRefusalText', () => { + it('keeps fatal/error lines, drops hints and control bytes', () => { + const t = gitRefusalText( + "fatal: '.claude/worktrees/a' contains modified or untracked files, use --force to delete it\nhint: x\n\u001b[31m" + ); + expect(t).toBe("fatal: '.claude/worktrees/a' contains modified or untracked files, use --force to delete it"); + }); +}); + +// ─── IO against a real repository ──────────────────────────────────────────── + +describe('git worktrees on a real repository', () => { + let dir: string; + let repoDir: string; + let repo: CaseRepository; + + beforeEach(async () => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-worktrees-'))); + repoDir = join(dir, 'repo'); + mkdirSync(repoDir); + git(repoDir, 'init', '-q', '-b', 'main'); + writeFileSync(join(repoDir, 'a.txt'), '1\n'); + git(repoDir, 'add', 'a.txt'); + git(repoDir, 'commit', '-q', '-m', 'base'); + const resolved = await resolveCaseRepository(repoDir, { home: '/nonexistent-home' }); + if (!resolved.ok) throw new Error(resolved.message); + repo = resolved.repo; + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves the repository root and the real common git dir', () => { + expect(repo.repoRoot).toBe(repoDir); + expect(repo.commonDir).toBe(join(repoDir, '.git')); + }); + + it('refuses a folder that is not a repository, and one below the top level', async () => { + const plain = join(dir, 'plain'); + mkdirSync(plain); + const notRepo = await resolveCaseRepository(plain, { home: '/nonexistent-home' }); + expect(notRepo).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + mkdirSync(join(repoDir, 'sub')); + const sub = await resolveCaseRepository(join(repoDir, 'sub'), { home: '/nonexistent-home' }); + expect(sub.ok).toBe(false); + if (!sub.ok) expect(sub.message).toContain('top of a git repository'); + }); + + it('treats a repository at the home folder above the case as no repository', async () => { + mkdirSync(join(repoDir, 'proj')); + const r = await resolveCaseRepository(join(repoDir, 'proj'), { home: repoDir }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toBe('This case folder is not a git repository.'); + }); + + it('never spawns git for a case inside a Docker case workspace', async () => { + let spawned = 0; + const spy: WorktreeGitRunner = async () => { + spawned++; + return { code: 0, stdout: '', stderr: '', timedOut: false }; + }; + const r = await resolveCaseRepository(repoDir, { git: spy, dockerWorkspaces: [dir] }); + expect(r).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + expect(spawned).toBe(0); + }); + + it('creates /.claude/worktrees/ on worktree-, excluded from the main checkout', async () => { + const created = await createCaseWorktree(repo, { name: 'fix-login' }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const wtPath = join(repoDir, '.claude', 'worktrees', 'fix-login'); + expect(created.worktree).toMatchObject({ path: wtPath, branch: 'worktree-fix-login', base: 'HEAD' }); + expect(created.worktree.excludeAdded).toEqual(['/.claude/worktrees/', '/.claude/settings.local.json']); + expect(readFileSync(join(wtPath, 'a.txt'), 'utf-8')).toBe('1\n'); + expect(git(wtPath, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('worktree-fix-login'); + // The main checkout stays clean: the worktree folder is excluded, not untracked. + expect(git(repoDir, 'status', '--porcelain')).toBe(''); + expect(readFileSync(join(repoDir, '.git', 'info', 'exclude'), 'utf-8')).toContain('/.claude/worktrees/'); + // A hooks file Codeman would write into the worktree does not make it dirty either. + mkdirSync(join(wtPath, '.claude'), { recursive: true }); + writeFileSync(join(wtPath, '.claude', 'settings.local.json'), '{}'); + expect(git(wtPath, 'status', '--porcelain')).toBe(''); + + // A second create with the same lines already present appends nothing. + const second = await createCaseWorktree(repo, { name: 'other', branch: 'feat/other' }); + expect(second.ok && second.worktree.excludeAdded).toEqual([]); + const listed = await listCaseWorktrees(repo); + expect(listed.ok).toBe(true); + if (!listed.ok) return; + expect(listed.worktrees.map((w) => [w.main, w.isCase, w.managed, w.name ?? null, w.branch])).toEqual([ + [true, true, false, null, 'main'], + [false, false, true, 'fix-login', 'worktree-fix-login'], + [false, false, true, 'other', 'feat/other'], + ]); + }); + + it('refuses an existing branch, a bad base, a bad branch and a taken folder before touching anything', async () => { + git(repoDir, 'branch', 'worktree-taken'); + expect(await createCaseWorktree(repo, { name: 'taken' })).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' }); + expect(await createCaseWorktree(repo, { name: 'x', base: 'nosuchref' })).toMatchObject({ + ok: false, + code: 'INVALID_INPUT', + }); + expect(await createCaseWorktree(repo, { name: 'x', branch: '@{-1}' })).toMatchObject({ + ok: false, + code: 'INVALID_INPUT', + }); + expect(await createCaseWorktree(repo, { name: '../escape' })).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + mkdirSync(join(repoDir, '.claude', 'worktrees', 'busy'), { recursive: true }); + expect(await createCaseWorktree(repo, { name: 'busy' })).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' }); + expect(git(repoDir, 'branch', '--list', 'worktree-busy')).toBe(''); + }); + + it('names a taken worktree name as such, not as its derived branch', async () => { + expect((await createCaseWorktree(repo, { name: 'dup' })).ok).toBe(true); + const again = await createCaseWorktree(repo, { name: 'dup' }); + expect(again).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' }); + if (!again.ok) expect(again.message).toBe('A worktree named "dup" already exists in this case.'); + }); + + it('says a bare repository is bare', async () => { + const bare = join(dir, 'bare.git'); + git(dir, 'init', '-q', '--bare', bare); + const r = await resolveCaseRepository(bare, { home: '/nonexistent-home' }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain('bare git repository'); + }); + + it('starts from a named base', async () => { + git(repoDir, 'checkout', '-q', '-b', 'side'); + writeFileSync(join(repoDir, 'b.txt'), '2\n'); + git(repoDir, 'add', 'b.txt'); + git(repoDir, 'commit', '-q', '-m', 'side'); + git(repoDir, 'checkout', '-q', 'main'); + const created = await createCaseWorktree(repo, { name: 'from-side', base: 'side' }); + expect(created.ok).toBe(true); + expect(readFileSync(join(repoDir, '.claude', 'worktrees', 'from-side', 'b.txt'), 'utf-8')).toBe('2\n'); + }); + + it('refuses to create through a symlinked .claude or worktrees directory', async () => { + const outside = join(dir, 'outside'); + mkdirSync(join(outside, 'worktrees'), { recursive: true }); + symlinkSync(outside, join(repoDir, '.claude')); + const viaClaude = await createCaseWorktree(repo, { name: 'evil' }); + expect(viaClaude).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + if (!viaClaude.ok) expect(viaClaude.message).toContain('symlink'); + rmSync(join(repoDir, '.claude')); + mkdirSync(join(repoDir, '.claude')); + symlinkSync(join(outside, 'worktrees'), join(repoDir, '.claude', 'worktrees')); + expect(await createCaseWorktree(repo, { name: 'evil' })).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('evil'); + }); + + it('removes a clean worktree, refuses a dirty one and a live one, and never force-deletes', async () => { + const created = await createCaseWorktree(repo, { name: 'w1' }); + expect(created.ok).toBe(true); + const wtPath = join(repoDir, '.claude', 'worktrees', 'w1'); + + const live = await removeCaseWorktree(repo, { name: 'w1', isInUse: (p) => p === wtPath }); + expect(live).toMatchObject({ ok: false, code: 'CONFLICT' }); + + writeFileSync(join(wtPath, 'scratch.txt'), 'untracked work'); + const dirty = await removeCaseWorktree(repo, { name: 'w1', isInUse: () => false }); + expect(dirty).toMatchObject({ ok: false, code: 'CONFLICT' }); + if (!dirty.ok) expect(dirty.gitMessage).toContain('contains modified or untracked files'); + expect(readFileSync(join(wtPath, 'scratch.txt'), 'utf-8')).toBe('untracked work'); + + rmSync(join(wtPath, 'scratch.txt')); + const clean = await removeCaseWorktree(repo, { name: 'w1', deleteBranch: true, isInUse: () => false }); + expect(clean).toMatchObject({ ok: true, removed: { name: 'w1', branch: 'worktree-w1', branchDeleted: true } }); + expect(git(repoDir, 'branch', '--list', 'worktree-w1')).toBe(''); + expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('w1'); + }); + + it('keeps an unmerged branch: git branch -d refuses, never -D', async () => { + await createCaseWorktree(repo, { name: 'w2' }); + const wtPath = join(repoDir, '.claude', 'worktrees', 'w2'); + writeFileSync(join(wtPath, 'new.txt'), 'x'); + git(wtPath, 'add', 'new.txt'); + git(wtPath, 'commit', '-q', '-m', 'work'); + const r = await removeCaseWorktree(repo, { name: 'w2', deleteBranch: true, isInUse: () => false }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.removed.branchDeleted).toBe(false); + expect(r.removed.branchError).toContain('not fully merged'); + expect(git(repoDir, 'branch', '--list', 'worktree-w2')).toContain('worktree-w2'); + }); + + it('only removes a registered worktree that is a real directory', async () => { + mkdirSync(join(repoDir, '.claude', 'worktrees', 'stray'), { recursive: true }); + expect(await removeCaseWorktree(repo, { name: 'stray', isInUse: () => false })).toMatchObject({ + ok: false, + code: 'NOT_FOUND', + }); + expect(await removeCaseWorktree(repo, { name: 'missing', isInUse: () => false })).toMatchObject({ + ok: false, + code: 'NOT_FOUND', + }); + // A symlink at the worktree's own place is refused, never followed. + await createCaseWorktree(repo, { name: 'real' }); + symlinkSync(join(repoDir, '.claude', 'worktrees', 'real'), join(repoDir, '.claude', 'worktrees', 'link')); + expect(await resolveManagedWorktree(repo, 'link')).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + }); + + it('refuses a locked worktree', async () => { + await createCaseWorktree(repo, { name: 'locked' }); + git(repoDir, 'worktree', 'lock', '--reason', 'agent busy', join(repoDir, '.claude', 'worktrees', 'locked')); + const r = await removeCaseWorktree(repo, { name: 'locked', isInUse: () => false }); + expect(r).toMatchObject({ ok: false, code: 'CONFLICT' }); + if (!r.ok) expect(r.message).toContain('agent busy'); + }); +}); diff --git a/test/routes/worktree-routes.test.ts b/test/routes/worktree-routes.test.ts new file mode 100644 index 00000000..088f1a9e --- /dev/null +++ b/test/routes/worktree-routes.test.ts @@ -0,0 +1,340 @@ +/** + * @fileoverview The worktree routes (`/api/cases/:name/worktrees`) and quick-start's `worktree` + * field, against a REAL temp git repository in the per-file temp HOME's case space + * (`test/setup.ts`). Covers the happy path end to end (create, list with live sessions, start a + * session in it, remove, branch delete) and every refusal: Docker and remote cases (git never + * spawned for Docker), a folder that is not a repository, bad names, a symlinked `.claude`, a + * dirty tree, a live session inside, and multi-user ownership. Port: N/A (app.inject()). + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; +import { registerWorktreeRoutes } from '../../src/web/routes/worktree-routes.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { userCasesDir } from '../../src/config/multiuser.js'; +import { Session } from '../../src/session.js'; +import type { WorktreeGitRunner } from '../../src/git-worktrees.js'; + +const ENV = { + ...process.env, + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + +/** A git repository with one commit at `dir`. */ +function makeRepo(dir: string): string { + mkdirSync(dir, { recursive: true }); + git(dir, 'init', '-q', '-b', 'main'); + writeFileSync(join(dir, 'README.md'), '# repo\n'); + git(dir, 'add', 'README.md'); + git(dir, 'commit', '-q', '-m', 'init'); + return realpathSync(dir); +} + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +/** + * The worktree routes plus the session routes (quick-start, POST /api/sessions), with the + * production envelope hook mirrored from server.ts: quick-start returns a bare object and relies + * on it for both the `{ success, data }` wrapper and the error-code status, as in + * case-clone-routes.test.ts. + */ +async function harness( + opts: { authUser?: { username: string; role: 'admin' | 'user' }; git?: WorktreeGitRunner } = {} +): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + if (opts.authUser) { + const authUser = opts.authUser; + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: typeof authUser }).authUser = authUser; + }); + } + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + const ctx = createMockRouteContext(); + registerWorktreeRoutes(app, ctx as never, { git: opts.git }); + registerSessionRoutes(app, ctx as never); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +let h: Harness; +let repo: string; + +beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + repo = makeRepo(join(CASES_DIR, 'wtcase')); + h = await harness(); +}); + +afterEach(async () => { + await h.app.close(); + vi.restoreAllMocks(); + delete process.env.CODEMAN_MULTIUSER; + await rm(join(getDataDir(), 'docker-hosts.json'), { force: true }); + await rm(join(getDataDir(), 'docker-cases.json'), { force: true }); + await rm(join(getDataDir(), 'remote-hosts.json'), { force: true }); + await rm(join(getDataDir(), 'remote-cases.json'), { force: true }); + await rm(join(getDataDir(), 'linked-cases.json'), { force: true }); + safeRmHomeTree(CASES_DIR); + safeRmHomeTree(join(process.env.HOME ?? '/nonexistent', 'codeman-users')); +}); + +const list = (name = 'wtcase') => h.app.inject({ method: 'GET', url: `/api/cases/${name}/worktrees` }); +const create = (payload: Record, name = 'wtcase') => + h.app.inject({ method: 'POST', url: `/api/cases/${name}/worktrees`, payload }); +const remove = (wt: string, query = '', name = 'wtcase') => + h.app.inject({ method: 'DELETE', url: `/api/cases/${name}/worktrees/${wt}${query}` }); + +describe('worktree routes: happy path', () => { + it('creates, lists, starts a session in, and removes a worktree', async () => { + const before = await list(); + expect(before.statusCode).toBe(200); + expect(before.json().data).toMatchObject({ caseName: 'wtcase', repoRoot: repo, truncated: false }); + expect(before.json().data.worktrees).toHaveLength(1); + + const res = await create({ name: 'fix-login' }); + expect(res.statusCode).toBe(201); + const wtPath = join(repo, '.claude', 'worktrees', 'fix-login'); + expect(res.json().data.worktree).toMatchObject({ name: 'fix-login', path: wtPath, branch: 'worktree-fix-login' }); + expect(git(repo, 'status', '--porcelain')).toBe(''); + + // Start a session in it through the EXISTING quick-start path. + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'claude', worktree: 'fix-login', sessionName: 'w1-fix-login' }, + }); + expect(qs.statusCode).toBe(200); + const qsData = qs.json().data; + expect(qsData).toMatchObject({ casePath: repo, worktree: 'fix-login', workingDir: wtPath }); + const session = h.ctx.sessions.get(qsData.sessionId) as unknown as { workingDir: string }; + expect(session.workingDir).toBe(wtPath); + // The session's hooks land in the worktree it runs in, not the case folder. + expect(readFileSync(join(wtPath, '.claude', 'settings.local.json'), 'utf-8')).toContain('/api/hook-event'); + // ...and that file does not make the worktree dirty (it is excluded). + expect(git(wtPath, 'status', '--porcelain')).toBe(''); + + const listed = (await list()).json().data.worktrees; + const row = listed.find((w: { name?: string }) => w.name === 'fix-login'); + expect(row).toMatchObject({ managed: true, branch: 'worktree-fix-login', sessionCount: 1 }); + expect(row.sessions).toEqual([{ id: qsData.sessionId, name: 'w1-fix-login' }]); + // The main checkout does not count a session that runs in a worktree below it. + expect(listed[0]).toMatchObject({ main: true, isCase: true, managed: false }); + + // Refused while that session lives. + const busy = await remove('fix-login'); + expect(busy.statusCode).toBe(409); + expect(busy.json().error).toContain('live session'); + + h.ctx.sessions.delete(qsData.sessionId); + const gone = await remove('fix-login', '?deleteBranch=true'); + expect(gone.statusCode).toBe(200); + expect(gone.json().data.removed).toMatchObject({ + name: 'fix-login', + branch: 'worktree-fix-login', + branchDeleted: true, + }); + expect(existsSync(wtPath)).toBe(false); + expect(git(repo, 'branch', '--list', 'worktree-fix-login')).toBe(''); + }); + + it('surfaces git’s refusal for a dirty tree and keeps the files', async () => { + await create({ name: 'dirty', branch: 'feat/dirty' }); + const wtPath = join(repo, '.claude', 'worktrees', 'dirty'); + writeFileSync(join(wtPath, 'wip.txt'), 'work'); + const res = await remove('dirty'); + expect(res.statusCode).toBe(409); + expect(res.json().error).toContain('contains modified or untracked files'); + expect(readFileSync(join(wtPath, 'wip.txt'), 'utf-8')).toBe('work'); + }); + + it('lets a POST /api/sessions create path use the worktree as workingDir', async () => { + await create({ name: 'viasessions' }); + const wtPath = join(repo, '.claude', 'worktrees', 'viasessions'); + const res = await h.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { workingDir: wtPath, mode: 'shell', name: 's1-viasessions' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().data.session.workingDir).toBe(wtPath); + }); +}); + +describe('worktree routes: refusals', () => { + it('rejects bad names before git (schema and the finer rules)', async () => { + for (const name of ['-x', 'a/b', '.hidden', 'x'.repeat(65), 'a..b', 'n.lock']) { + const res = await create({ name }); + expect(res.statusCode, name).toBe(400); + } + expect((await remove('a..b')).statusCode).toBe(400); + expect((await create({ name: 'ok', branch: '-D' })).statusCode).toBe(400); + expect((await create({ name: 'ok', base: '--upload-pack=sh' })).statusCode).toBe(400); + expect(git(repo, 'worktree', 'list')).not.toContain('ok'); + }); + + it('answers 404 for a missing case and 400 for a folder that is not a repository', async () => { + expect((await list('nosuchcase')).statusCode).toBe(404); + mkdirSync(join(CASES_DIR, 'plain'), { recursive: true }); + const res = await list('plain'); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('not a git repository'); + }); + + it('refuses a Docker case with a 400 and never spawns git', async () => { + let spawned = 0; + const spy: WorktreeGitRunner = async () => { + spawned++; + return { code: 0, stdout: '', stderr: '', timedOut: false }; + }; + await h.app.close(); + h = await harness({ git: spy }); + await mkdir(getDataDir(), { recursive: true }); + await writeFile( + join(getDataDir(), 'docker-hosts.json'), + JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }]) + ); + await writeFile( + join(getDataDir(), 'docker-cases.json'), + JSON.stringify([{ name: 'dockcase', type: 'docker', hostId: 'd1', hostWorkspacePath: repo }]) + ); + for (const res of [ + await list('dockcase'), + await create({ name: 'x' }, 'dockcase'), + await remove('x', '', 'dockcase'), + ]) { + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('Docker'); + } + // The local case whose folder IS that Docker workspace is refused too (git would run there). + const viaLocal = await list('wtcase'); + expect(viaLocal.statusCode).toBe(400); + expect(viaLocal.json().error).toContain('Docker'); + expect(spawned).toBe(0); + + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'dockcase', mode: 'shell', worktree: 'x' }, + }); + expect(qs.statusCode).toBe(400); + expect(qs.json().error).toContain('Docker'); + }); + + it('refuses a remote case with a 400', async () => { + await mkdir(getDataDir(), { recursive: true }); + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'r1', label: 'far', host: 'far.example', username: 'u' }]) + ); + await writeFile( + join(getDataDir(), 'remote-cases.json'), + JSON.stringify([{ name: 'farcase', type: 'remote', hostId: 'r1', remotePath: '/srv/repo' }]) + ); + const res = await list('farcase'); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('remote'); + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'farcase', mode: 'shell', worktree: 'x' }, + }); + expect(qs.statusCode).toBe(400); + expect(qs.json().error).toContain('remote'); + }); + + it('refuses to create through a symlinked .claude directory', async () => { + const outside = join(CASES_DIR, 'outside'); + mkdirSync(join(outside, 'worktrees'), { recursive: true }); + symlinkSync(outside, join(repo, '.claude')); + const res = await create({ name: 'evil' }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('symlink'); + expect(existsSync(join(outside, 'worktrees', 'evil'))).toBe(false); + }); + + it('quick-start refuses an unregistered worktree and never scaffolds a missing case', async () => { + mkdirSync(join(repo, '.claude', 'worktrees', 'stray'), { recursive: true }); + const stray = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'shell', worktree: 'stray' }, + }); + expect(stray.statusCode).toBe(404); + const missing = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'brandnew', mode: 'shell', worktree: 'x' }, + }); + expect(missing.statusCode).toBe(404); + expect(existsSync(join(CASES_DIR, 'brandnew'))).toBe(false); + const badName = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'shell', worktree: '../x' }, + }); + expect(badName.statusCode).toBe(400); + }); +}); + +describe('worktree routes: multi-user ownership', () => { + it('resolves a non-admin’s own case space only, never a linked case or another user’s case', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + // A linked case is admin-only: a non-admin's lookup falls through to their own space. + await mkdir(getDataDir(), { recursive: true }); + await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedrepo: repo })); + makeRepo(join(userCasesDir('alice'), 'alicecase')); + + await h.app.close(); + h = await harness({ authUser: { username: 'bob', role: 'user' } }); + expect((await list('linkedrepo')).statusCode).toBe(404); + expect((await list('alicecase')).statusCode).toBe(404); + expect((await create({ name: 'x' }, 'alicecase')).statusCode).toBe(404); + expect(existsSync(join(userCasesDir('alice'), 'alicecase', '.claude'))).toBe(false); + + await h.app.close(); + h = await harness({ authUser: { username: 'alice', role: 'user' } }); + expect((await list('alicecase')).statusCode).toBe(200); + expect((await create({ name: 'mine' }, 'alicecase')).statusCode).toBe(201); + + await h.app.close(); + h = await harness({ authUser: { username: 'root', role: 'admin' } }); + const linked = await list('linkedrepo'); + expect(linked.statusCode).toBe(200); + expect(linked.json().data.repoRoot).toBe(repo); + }); +}); diff --git a/test/session-worktree-label.test.ts b/test/session-worktree-label.test.ts index 53764112..826df58a 100644 --- a/test/session-worktree-label.test.ts +++ b/test/session-worktree-label.test.ts @@ -45,6 +45,24 @@ describe('worktree fields through the unified merge (#266)', () => { expect(merged[0].gitBranch).toBeUndefined(); }); + it('names the worktree of a LIVE session from its folder layout (no transcript record yet)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'live1', name: 'w1-fix-login', mode: 'codex', workingDir: '/repo/.claude/worktrees/fix-login' }], + }); + expect(merged[0]).toMatchObject({ worktreeName: 'fix-login', worktreeRepo: '/repo' }); + }); + + it('lets a transcript record win over the layout, and ignores a subfolder of a worktree', () => { + const fromTranscript = mergeUnifiedSessions({ + history: [historyRow({ worktreeName: 'named-by-claude', worktreeRepo: '/elsewhere' })], + }); + expect(fromTranscript[0]).toMatchObject({ worktreeName: 'named-by-claude', worktreeRepo: '/elsewhere' }); + const sub = mergeUnifiedSessions({ + live: [{ id: 'live2', name: 'x', workingDir: '/repo/.claude/worktrees/fix-login/src' }], + }); + expect(sub[0].worktreeName).toBeUndefined(); + }); + it('finds a session by worktree name and by branch', () => { const items = [ { sessionId: 'a', worktreeName: 'autodev', sources: ['history'] },