mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02: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 } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
|
||||
if (value !== undefined) item[key] = value;
|
||||
}
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
|
||||
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
|
||||
|
||||
/**
|
||||
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
|
||||
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
|
||||
*/
|
||||
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
|
||||
const m = WORKTREE_PATH.exec(workingDir);
|
||||
return m ? { repo: m[1], name: m[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all source views into one list, applying precedence
|
||||
* (history → lifecycle → persisted → live) and the meaningfulness floor.
|
||||
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
|
||||
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
|
||||
// shell session never gets one, so its row read like any other session of the repo. The
|
||||
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
|
||||
if (!item.worktreeName && item.workingDir) {
|
||||
const wt = worktreeFromPath(item.workingDir);
|
||||
if (wt) {
|
||||
item.worktreeName = wt.name;
|
||||
item.worktreeRepo ??= wt.repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof Session>[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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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 `<case>/.claude/worktrees/<name>` 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: <name>` (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<string[]> =>
|
||||
(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;
|
||||
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
|
||||
worktreesDir: string;
|
||||
worktrees: CaseWorktreeInfo[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
type Fail = ApiResponse<never>;
|
||||
|
||||
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<Record<string, string>>(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<string[]> } = {}
|
||||
): 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<ApiResponse<CaseWorktreeList>> => {
|
||||
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<ApiResponse<{ worktree: CreatedWorktree }>> => {
|
||||
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<ApiResponse<{ removed: RemovedWorktree }>> => {
|
||||
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 } };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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-<name>`, 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 (`<case>/.claude/worktrees/<worktree>`,
|
||||
* 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 <case>/.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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <repo>/.claude/worktrees/<valid-name>', () => {
|
||||
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 <repo>/.claude/worktrees/<name> on worktree-<name>, 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');
|
||||
});
|
||||
});
|
||||
@@ -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<Harness> {
|
||||
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<string, unknown>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
|
||||
Reference in new issue
Block a user