/** * src/http/worktrees.ts (v0.6 project detail) — list a repo's git worktrees. * * `git worktree list --porcelain` emits blank-line-separated records; the first * record is the main worktree. Best-effort: a non-repo / git error yields []. */ import fs from 'node:fs/promises' import path from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import type { CreateWorktreeResult, PruneWorktreesResult, RemoveWorktreeResult, WorktreeInfo, } from '../types.js' const execFileAsync = promisify(execFile) const WORKTREE_TIMEOUT_MS = 2000 const WORKTREE_MAX_BUFFER = 1024 * 1024 const MAX_BRANCH_LEN = 250 // git ref-name forbidden punctuation subset + backslash (SEC-H2). Control chars // and whitespace are matched separately via \s and the \x00-\x1f\x7f range. const FORBIDDEN_BRANCH_CHARS = /[\x00-\x1f\x7f\s~^:?*[\\]/ /** * Parse `git worktree list --porcelain`. `currentPath` flags which worktree is * the requested project (isCurrent). The first record is the main worktree. * Pure + unit-tested. */ export function parseWorktrees(porcelain: string, currentPath: string): WorktreeInfo[] { const out: WorktreeInfo[] = [] let cur: { path?: string; branch?: string; head?: string; locked?: boolean; prunable?: boolean } = {} const flush = (): void => { if (cur.path === undefined) return out.push({ path: cur.path, branch: cur.branch, head: cur.head, isMain: out.length === 0, // git lists the main worktree first isCurrent: cur.path === currentPath, locked: cur.locked, prunable: cur.prunable, }) cur = {} } for (const raw of porcelain.split('\n')) { const line = raw.trimEnd() if (line === '') { flush() continue } const sep = line.indexOf(' ') const key = sep === -1 ? line : line.slice(0, sep) const val = sep === -1 ? '' : line.slice(sep + 1) if (key === 'worktree') cur.path = val else if (key === 'HEAD') cur.head = val.slice(0, 8) else if (key === 'branch') cur.branch = val.replace(/^refs\/heads\//, '') else if (key === 'detached') cur.branch = undefined else if (key === 'locked') cur.locked = true else if (key === 'prunable') cur.prunable = true } flush() // final record has no trailing blank line return out } /** List worktrees for a repo (execFile, no shell); [] on any error/non-repo. */ export async function listWorktrees(repoPath: string): Promise { try { const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath, timeout: WORKTREE_TIMEOUT_MS, maxBuffer: WORKTREE_MAX_BUFFER, }) return parseWorktrees(stdout, repoPath) } catch { return [] } } // ── B3: create a worktree (validate → contain → execFile, no shell) ───────────── /** * Validate a user-supplied branch name against a git-ref-name subset (SEC-H2). * Rejects: empty / >250 chars / leading '-' (flag injection) / '..' / trailing * '.lock' or '.' / control chars / whitespace / ~^:?*[\ / '@{' / leading, * trailing or consecutive '/'. Pure + unit-tested. */ export function validateBranchName(branch: string): boolean { if (typeof branch !== 'string') return false if (branch.length === 0 || branch.length > MAX_BRANCH_LEN) return false if (branch.startsWith('-')) return false if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) return false if (branch.includes('..')) return false if (branch.endsWith('.lock') || branch.endsWith('.')) return false if (branch.includes('@{')) return false if (FORBIDDEN_BRANCH_CHARS.test(branch)) return false return true } /** * Reduce a branch name to a single safe filesystem directory segment: '/' → '-', * any non-[A-Za-z0-9._-] char → '-', then trim leading/trailing dashes. Pure. */ export function sanitizeBranchForDir(branch: string): string { return branch .replace(/\//g, '-') .replace(/[^A-Za-z0-9._-]/g, '-') .replace(/^-+|-+$/g, '') } /** * Resolve symlinks on the longest existing prefix of `target`, re-appending any * not-yet-existing trailing segments. Lets us realpath a path we're about to * create without it existing yet (SEC-H3/M2). */ async function resolveRealPath(target: string): Promise { let current = path.resolve(target) const tail: string[] = [] for (;;) { try { const real = await fs.realpath(current) return tail.length === 0 ? real : path.join(real, ...tail.slice().reverse()) } catch { const parent = path.dirname(current) if (parent === current) return path.resolve(target) // reached an unresolvable root tail.push(path.basename(current)) current = parent } } } /** * Compute the absolute directory for a new worktree and prove it is contained in * the controlled base. base = `root ?? /-worktrees`. * Both base and the candidate are realpath-resolved (symlinks followed) before a * `startsWith(realBase + sep)` containment check — defeating symlinked-root / * pre-planted-symlink escapes (M2). Throws when the candidate escapes the base. */ export async function computeWorktreeDir( repoPath: string, sanitized: string, root?: string, ): Promise { const base = root ?? path.join(path.dirname(repoPath), path.basename(repoPath) + '-worktrees') const candidate = path.join(base, sanitized) const realBase = await resolveRealPath(base) const realCandidate = await resolveRealPath(candidate) if (realCandidate !== realBase && realCandidate.startsWith(realBase + path.sep)) { return candidate } throw new Error('worktree path escapes the controlled root') } /** True iff `/.git` exists (file or directory). */ async function hasGitEntry(dir: string): Promise { try { await fs.stat(path.join(dir, '.git')) return true } catch { return false } } /** Three-prong entry check: absolute + isDirectory + has a .git entry. */ async function isGitRepo(repoPath: string): Promise { if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false try { const stat = await fs.stat(repoPath) if (!stat.isDirectory()) return false } catch { return false } return hasGitEntry(repoPath) } /** Pull a best-effort stderr string off a child_process error (never throws). */ function extractStderr(err: unknown): string { if (typeof err === 'object' && err !== null) { const e = err as { stderr?: unknown; message?: unknown } if (typeof e.stderr === 'string') return e.stderr if (typeof e.message === 'string') return e.message } return '' } /** * Map a `git worktree add` failure to a structured result with a SAFE message * (never raw git stderr, SEC-M10). Branch/path-exists collisions → 409, else 500. */ function classifyWorktreeError(err: unknown): CreateWorktreeResult { const s = extractStderr(err).toLowerCase() if (s.includes('already checked out') || s.includes('already used by worktree')) { return { ok: false, status: 409, error: 'That branch is already checked out in another worktree.' } } if (s.includes('already exists') && s.includes('branch')) { return { ok: false, status: 409, error: 'A branch with that name already exists.' } } if (s.includes('already exists')) { return { ok: false, status: 409, error: 'The target directory already exists.' } } return { ok: false, status: 500, error: 'Failed to create the worktree.' } } export interface CreateWorktreeOptions { readonly base?: string // optional commit-ish to branch from (FR-B3.9) readonly worktreeRoot?: string // cfg.worktreeRoot; undefined → -worktrees readonly timeoutMs: number // cfg.worktreeTimeoutMs } /** * Create a git worktree on a new branch. Validates the branch, three-prong * checks the repo, computes a contained target dir, then runs * `git worktree add -b -- []` via execFile (no shell, `--` * terminates options). Returns a structured CreateWorktreeResult; never throws. */ export async function createWorktree( repoPath: string, branch: string, opts: CreateWorktreeOptions, ): Promise { if (!validateBranchName(branch)) { return { ok: false, status: 400, error: 'Invalid branch name.' } } if (!(await isGitRepo(repoPath))) { return { ok: false, status: 404, error: 'Not a git repository.' } } const sanitized = sanitizeBranchForDir(branch) let dir: string try { dir = await computeWorktreeDir(repoPath, sanitized, opts.worktreeRoot) } catch { return { ok: false, status: 400, error: 'Resolved worktree path is out of bounds.' } } try { const args = ['worktree', 'add', '-b', branch, '--', dir] if (opts.base !== undefined && opts.base !== '') args.push(opts.base) await execFileAsync('git', args, { cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: WORKTREE_MAX_BUFFER, }) return { ok: true, path: dir, branch } } catch (err: unknown) { return classifyWorktreeError(err) } } // ── W4: remove a worktree (validate → registered-check → contain → execFile) ──── export interface RemoveWorktreeOptions { readonly force?: boolean // git's own --force (required for a dirty tree) readonly timeoutMs: number // cfg.worktreeTimeoutMs } /** * Map a `git worktree remove` failure to a structured result with a SAFE message * (never raw git stderr, SEC-M10). A dirty tree (git demands --force) → 409; an * already-gone / not-a-working-tree race → 404; anything else → 500. */ function classifyRemoveError(err: unknown): RemoveWorktreeResult { const s = extractStderr(err).toLowerCase() if ( s.includes('contains modified or untracked files') || s.includes('use --force') || s.includes('use `--force`') || s.includes('is dirty') ) { return { ok: false, status: 409, error: 'Worktree has uncommitted changes — force required.' } } if (s.includes('not a working tree') || s.includes('is not a working tree')) { return { ok: false, status: 404, error: 'That path is not a worktree of this repository.' } } return { ok: false, status: 500, error: 'Failed to remove the worktree.' } } /** * Remove a git worktree — the destructive path. The security spine (never `rm`, * always via git): * 1. `isGitRepo(repoPath)` gate → 404. * 2. non-empty string target → 400. * 3. the target's REALPATH must equal the realpath of an entry git itself * reports in `worktree list` (canonical compare, defeats symlink tricks); * no match → 404. This registered-check IS the containment (M2-consistent): * an arbitrary FS path (e.g. /etc) can never match, so it is never touched. * 4. the matched entry may not be the MAIN worktree → 400 (never delete the repo). * 5. a LOCKED entry → 409 (unlock in a terminal first; never auto -f -f). * 6. run `git worktree remove [--force] -- ` via * execFile (no shell, `--` terminates options), never the raw user string. * Returns a structured RemoveWorktreeResult; never throws. */ export async function removeWorktree( repoPath: string, targetPath: string, opts: RemoveWorktreeOptions, ): Promise { if (!(await isGitRepo(repoPath))) { return { ok: false, status: 404, error: 'Not a git repository.' } } if (typeof targetPath !== 'string' || targetPath.length === 0) { return { ok: false, status: 400, error: 'Worktree path is required.' } } // Canonicalise the requested path and every registered worktree, then match on // realpath — a symlink alias resolves to the same canonical target (M2). const realTarget = await resolveRealPath(targetPath) const worktrees = await listWorktrees(repoPath) let match: WorktreeInfo | undefined for (const wt of worktrees) { if ((await resolveRealPath(wt.path)) === realTarget) { match = wt break } } if (match === undefined) { return { ok: false, status: 404, error: 'That path is not a worktree of this repository.' } } if (match.isMain) { return { ok: false, status: 400, error: 'Cannot remove the main worktree.' } } if (match.locked === true) { return { ok: false, status: 409, error: 'This worktree is locked; unlock it in a terminal first.' } } try { const args = ['worktree', 'remove', ...(opts.force === true ? ['--force'] : []), '--', match.path] await execFileAsync('git', args, { cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: WORKTREE_MAX_BUFFER, }) return { ok: true, path: match.path } } catch (err: unknown) { return classifyRemoveError(err) } } // ── W4: prune stale worktrees (folders that git can no longer find) ───────────── export interface PruneWorktreesOptions { readonly timeoutMs: number // cfg.worktreeTimeoutMs } /** * Parse `git worktree prune -v` output into best-effort human labels. git emits * one `Removing : ` line per reclaimed entry (on stdout and/or * stderr depending on version) — capture the label before the ':'. Pure. */ function parsePruneOutput(text: string): string[] { const out: string[] = [] for (const raw of text.split('\n')) { const line = raw.trim() const m = /^Removing\s+(.+?):/.exec(line) if (m !== null && m[1] !== undefined) out.push(m[1]) } return out } /** * Prune worktrees whose working directories are gone. `isGitRepo` gate (404), * then `git worktree prune -v` via execFile (no shell). Idempotent — a clean repo * yields `{ ok:true, pruned:[] }`. Returns a structured result; never throws. */ export async function pruneWorktrees( repoPath: string, opts: PruneWorktreesOptions, ): Promise { if (!(await isGitRepo(repoPath))) { return { ok: false, status: 404, error: 'Not a git repository.' } } try { const { stdout, stderr } = await execFileAsync('git', ['worktree', 'prune', '-v'], { cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: WORKTREE_MAX_BUFFER, }) return { ok: true, pruned: parsePruneOutput(`${stdout}\n${stderr}`) } } catch { return { ok: false, status: 500, error: 'Failed to prune worktrees.' } } }