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:
@@ -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.' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
20
src/types.ts
20
src/types.ts
@@ -594,6 +594,26 @@ export interface CreateWorktreeResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of DELETE /projects/worktree (W4 remove). `error` is a SAFE message
|
||||
* only (never raw git stderr, SEC-M10); `status` is the HTTP status to return.
|
||||
* `path` echoes git's own canonical worktree path that was removed. */
|
||||
export interface RemoveWorktreeResult {
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of POST /projects/worktree/prune (W4). `pruned` lists best-effort
|
||||
* human labels of the stale worktrees git reclaimed (empty = nothing to prune,
|
||||
* idempotent). `error` is a SAFE message only (SEC-M10). */
|
||||
export interface PruneWorktreesResult {
|
||||
ok: boolean;
|
||||
pruned?: string[];
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
|
||||
|
||||
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).
|
||||
|
||||
Reference in New Issue
Block a user