mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(worktrees): create, list and remove git worktrees of a local case
Sessions in one case share its folder, so several agents on one repository edit, build and commit in the same checkout and step on each other. A case whose folder is a git repository can now keep worktrees under <repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>), which is Claude Code's own --worktree layout (measured in Claude Code 2.1.296) and the one Codeman's worktree pill already recognizes. New module src/git-worktrees.ts (pure name/branch gates, worktree list parsing, exclude text; IO through an injectable runner) and routes GET/POST /api/cases/:name/worktrees and DELETE /api/cases/:name/worktrees/:worktree: - git runs as argv with -- before paths, non-interactive env, process group timeouts, --no-optional-locks on reads; names are gated before git and confirmed with git check-ref-format --branch. - Only <repo>/.claude/worktrees/<valid-name> is created or removed; every path component is lstat'ed and realpath-checked, so a symlinked .claude cannot redirect a create or remove outside the repository. - /.claude/worktrees/ (and the per-workspace hooks file Codeman writes, which otherwise blocks git worktree remove) go to the repository's local info/exclude, never a tracked .gitignore. - Removal is git worktree remove WITHOUT --force, refused while a live session runs inside; branch delete is git branch -d, never -D. - Docker and remote cases are refused with a 400, and git never runs for a case at or inside a Docker case workspace. Starting a session reuses the existing create paths: quick-start takes a worktree name (validated, local cases only) and runs hooks, the skill, the model override and the session itself in the worktree; POST /api/sessions takes the worktree as workingDir. The unified session list names the worktree of a live session from its folder layout, so a codex or shell session in one gets the worktree pill too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a8a8bb71a3
commit
517e12fe1d
10 files changed
+1963
-9
No files matched your search
@@ -0,0 +1,870 @@
|
||||
/**
|
||||
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
|
||||
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, 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. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
|
||||
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
|
||||
* branch `worktree-<name>`, 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 <name>` 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 `<repo>/.claude/worktrees/<valid-name>` 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-<name>`. */
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<repo>/.claude/worktrees`. */
|
||||
export function managedWorktreesDir(repoRoot: string): string {
|
||||
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
|
||||
}
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>`. 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
|
||||
* `<repoRoot>/.claude/worktrees/<valid-name>`. 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<string, number> = { 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 <args>` 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<WorktreeGitResult>;
|
||||
|
||||
/**
|
||||
* 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<WorktreeGitResult>((resolveRun) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
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<string> => {
|
||||
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 `<repo>/.claude/worktrees/<valid-name>`: 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<DirState> {
|
||||
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 `<repo>/.claude` and `<repo>/.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 `<commonDir>/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<string, Promise<unknown>>();
|
||||
|
||||
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
|
||||
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
|
||||
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-<name>`. */
|
||||
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<boolean>;
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user