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:
@@ -190,6 +190,176 @@ describe('POST /projects/worktree (B3)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── DELETE /projects/worktree (W4 remove) ─────────────────────────────────────────
|
||||
|
||||
/** Create a worktree via the POST route; returns its git path. */
|
||||
async function createWorktreeViaRoute(
|
||||
port: number,
|
||||
origin: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo, branch }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; path?: string }
|
||||
expect(body.ok).toBe(true)
|
||||
return body.path as string
|
||||
}
|
||||
|
||||
describe('DELETE /projects/worktree (W4)', () => {
|
||||
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
|
||||
body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a missing Origin with 403', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 403 when WORKTREE_ENABLED=0', async () => {
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 when worktreePath is missing', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: '/tmp/x' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('refuses to remove the main worktree with 400', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo, worktreePath: repo }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('removes a clean linked worktree and returns ok (200)', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-rmroot-'))
|
||||
tmpDirs.push(root)
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
|
||||
const wt = await createWorktreeViaRoute(port, origin, repo, 'to-remove')
|
||||
expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).toContain(wt)
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo, worktreePath: wt }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; path?: string }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt)
|
||||
})
|
||||
|
||||
itGit('refuses a dirty worktree (409), then removes it with force (200)', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-dirtyroot-'))
|
||||
tmpDirs.push(root)
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
|
||||
const wt = await createWorktreeViaRoute(port, origin, repo, 'dirty-one')
|
||||
await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8')
|
||||
|
||||
const refused = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo, worktreePath: wt }),
|
||||
})
|
||||
expect(refused.status).toBe(409)
|
||||
|
||||
const forced = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo, worktreePath: wt, force: true }),
|
||||
})
|
||||
expect(forced.status).toBe(200)
|
||||
expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt)
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /projects/worktree/prune (W4) ────────────────────────────────────────────
|
||||
|
||||
describe('POST /projects/worktree/prune (W4)', () => {
|
||||
it('rejects a foreign Origin with 403', async () => {
|
||||
const { port } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
|
||||
body: JSON.stringify({ path: '/tmp/x' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 403 when WORKTREE_ENABLED=0', async () => {
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: '/tmp/x' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const { port, origin } = await spawnServer()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
itGit('prunes a worktree whose folder was deleted out-of-band (200)', async () => {
|
||||
const repo = await makeRealRepo()
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pruneroot-'))
|
||||
tmpDirs.push(root)
|
||||
const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
|
||||
const wt = await createWorktreeViaRoute(port, origin, repo, 'stale-one')
|
||||
await fs.rm(wt, { recursive: true, force: true })
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin },
|
||||
body: JSON.stringify({ path: repo }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean; pruned: string[] }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(Array.isArray(body.pruned)).toBe(true)
|
||||
expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt)
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /projects/diff ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /projects/diff (B1)', () => {
|
||||
|
||||
Reference in New Issue
Block a user