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

@@ -44,7 +44,7 @@ import { getGitLog } from './http/git-log.js'
import { buildDigest, clampSince } from './http/digest.js'
import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree } from './http/worktrees.js'
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
@@ -928,6 +928,58 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' })
})
// ── W4 remove a git worktree (destructive → Origin guard, same as create) ────
app.delete('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.worktreeEnabled) {
res.status(403).json({ error: 'Worktree management is disabled.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const worktreePath = typeof body['worktreePath'] === 'string' ? body['worktreePath'] : undefined
const force = body['force'] === true
if (repoPath === undefined || worktreePath === undefined) {
res.status(400).json({ error: 'path and worktreePath are required' })
return
}
// Audit (destructive): who/what, sanitized + truncated (never raw control chars).
console.error(
`[server] worktree remove: path=${sanitizeForLog(repoPath)} worktree=${sanitizeForLog(worktreePath)} force=${force}`,
)
const result = await removeWorktree(repoPath, worktreePath, {
force,
timeoutMs: cfg.worktreeTimeoutMs,
})
if (result.ok) {
res.status(200).json({ ok: true, path: result.path })
return
}
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to remove the worktree.' })
})
// ── W4 prune stale worktrees (destructive → Origin guard) ────────────────────
app.post('/projects/worktree/prune', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.worktreeEnabled) {
res.status(403).json({ error: 'Worktree management is disabled.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
console.error(`[server] worktree prune: path=${sanitizeForLog(repoPath)}`)
const result = await pruneWorktrees(repoPath, { timeoutMs: cfg.worktreeTimeoutMs })
if (result.ok) {
res.status(200).json({ ok: true, pruned: result.pruned ?? [] })
return
}
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = {