Closes the create-only loop — delete losing worktrees and prune stale ones without a terminal. Destructive, so guarded hard: - src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never rm -rf; git worktree remove [--force] / git worktree prune). Safeguards: (1) target realpath must match an entry git itself reports in `git worktree list` for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never invoked against them); (2) reject the MAIN worktree → 400; (3) realpath containment on request + each list entry, operating on git's canonical path (+ --); (4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors classified to fixed safe strings (raw stderr/paths never leaked). - DELETE /projects/worktree + POST /projects/worktree/prune — both behind requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route. - public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked) + a prune button; force needs an explicit second confirm (no single-click data loss). Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force / clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
/**
|
|
* 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<WorktreeInfo[]> {
|
|
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<string> {
|
|
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 ?? <dirname(repo)>/<basename(repo)>-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<string> {
|
|
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 `<dir>/.git` exists (file or directory). */
|
|
async function hasGitEntry(dir: string): Promise<boolean> {
|
|
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<boolean> {
|
|
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 → <repo>-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 <branch> -- <dir> [<base>]` via execFile (no shell, `--`
|
|
* terminates options). Returns a structured CreateWorktreeResult; never throws.
|
|
*/
|
|
export async function createWorktree(
|
|
repoPath: string,
|
|
branch: string,
|
|
opts: CreateWorktreeOptions,
|
|
): Promise<CreateWorktreeResult> {
|
|
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] -- <git's own canonical path>` 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<RemoveWorktreeResult> {
|
|
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 <name>: <reason>` 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<PruneWorktreesResult> {
|
|
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.' }
|
|
}
|
|
}
|