feat(projects): worktree remove + prune from any device (W4)

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.
This commit is contained in:
Yaojia Wang
2026-07-12 21:44:23 +02:00
parent 1dd12b035a
commit 552f35c690
7 changed files with 801 additions and 5 deletions

View File

@@ -9,7 +9,12 @@ 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, WorktreeInfo } from '../types.js'
import type {
CreateWorktreeResult,
PruneWorktreesResult,
RemoveWorktreeResult,
WorktreeInfo,
} from '../types.js'
const execFileAsync = promisify(execFile)
@@ -249,3 +254,137 @@ export async function createWorktree(
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.' }
}
}