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.
187 lines
8.1 KiB
TypeScript
187 lines
8.1 KiB
TypeScript
/**
|
|
* test/http/worktrees-remove.test.ts — W4 worktree remove / prune (DESTRUCTIVE).
|
|
*
|
|
* Covers the two new exports of src/http/worktrees.ts:
|
|
* - removeWorktree (registered-check + main/locked reject + realpath match +
|
|
* dirty/force handling + safe error classification, SEC-M10)
|
|
* - pruneWorktrees (idempotent prune of stale worktrees)
|
|
*
|
|
* Everything runs against real temporary git repos with real linked worktrees
|
|
* (no network, no mocks) — mirrors worktrees-create.test.ts. Safety is the point:
|
|
* these tests assert git is never invoked against an arbitrary / main / escaping
|
|
* path, and that a dirty tree is refused without an explicit force.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import fs from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { execFile } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import { createWorktree, removeWorktree, pruneWorktrees } from '../../src/http/worktrees.js'
|
|
|
|
const execFileP = promisify(execFile)
|
|
const TIMEOUT_MS = 10000
|
|
|
|
let gitAvailable = false
|
|
beforeAll(async () => {
|
|
try {
|
|
await execFileP('git', ['--version'])
|
|
gitAvailable = true
|
|
} catch {
|
|
gitAvailable = false
|
|
}
|
|
})
|
|
|
|
/** Create a temp git repo with one commit so `git worktree add` can run. */
|
|
async function makeRepo(): Promise<string> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-repo-'))
|
|
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
|
|
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir })
|
|
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir })
|
|
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir })
|
|
await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir })
|
|
return dir
|
|
}
|
|
|
|
/** Create a repo + one linked worktree (via the shipped createWorktree). */
|
|
async function makeRepoWithWorktree(branch = 'feature'): Promise<{ repo: string; wt: string }> {
|
|
const repo = await makeRepo()
|
|
const res = await createWorktree(repo, branch, { timeoutMs: TIMEOUT_MS })
|
|
expect(res.ok).toBe(true)
|
|
return { repo, wt: res.path as string }
|
|
}
|
|
|
|
async function worktreeList(repo: string): Promise<string> {
|
|
const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo })
|
|
return stdout
|
|
}
|
|
|
|
// ── removeWorktree ──────────────────────────────────────────────────────────────
|
|
|
|
describe('removeWorktree', () => {
|
|
it('returns 404 for a non-git directory', async () => {
|
|
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-plain-'))
|
|
const res = await removeWorktree(plain, plain, { timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: false, status: 404 })
|
|
})
|
|
|
|
it('returns 400 for an empty target path', async () => {
|
|
if (!gitAvailable) return
|
|
const repo = await makeRepo()
|
|
const res = await removeWorktree(repo, '', { timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: false, status: 400 })
|
|
})
|
|
|
|
it('returns 404 for a path that is not a registered worktree (never rm arbitrary paths)', async () => {
|
|
if (!gitAvailable) return
|
|
const repo = await makeRepo()
|
|
const stranger = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-stranger-'))
|
|
const res = await removeWorktree(repo, stranger, { timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: false, status: 404 })
|
|
// The stranger dir was never touched by git.
|
|
await expect(fs.stat(stranger)).resolves.toBeDefined()
|
|
})
|
|
|
|
it('refuses to remove the MAIN worktree (the repo itself) with 400', async () => {
|
|
if (!gitAvailable) return
|
|
const repo = await makeRepo()
|
|
const res = await removeWorktree(repo, repo, { timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: false, status: 400 })
|
|
expect(res.error).toMatch(/main/i)
|
|
// The repo still exists and is still a git repo.
|
|
await expect(fs.stat(path.join(repo, '.git'))).resolves.toBeDefined()
|
|
})
|
|
|
|
it('removes a clean linked worktree and returns ok', async () => {
|
|
if (!gitAvailable) return
|
|
const { repo, wt } = await makeRepoWithWorktree()
|
|
expect(await worktreeList(repo)).toContain(wt)
|
|
const res = await removeWorktree(repo, wt, { timeoutMs: TIMEOUT_MS })
|
|
expect(res.ok).toBe(true)
|
|
expect(res.path).toBeDefined()
|
|
expect(await worktreeList(repo)).not.toContain(wt)
|
|
})
|
|
|
|
it('refuses a dirty worktree without force (409), then removes it with force', async () => {
|
|
if (!gitAvailable) return
|
|
const { repo, wt } = await makeRepoWithWorktree()
|
|
// Untracked file → git refuses `worktree remove` without --force.
|
|
await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8')
|
|
|
|
const refused = await removeWorktree(repo, wt, { timeoutMs: TIMEOUT_MS })
|
|
expect(refused).toMatchObject({ ok: false, status: 409 })
|
|
// Still present — no data was lost.
|
|
expect(await worktreeList(repo)).toContain(wt)
|
|
|
|
const forced = await removeWorktree(repo, wt, { force: true, timeoutMs: TIMEOUT_MS })
|
|
expect(forced.ok).toBe(true)
|
|
expect(await worktreeList(repo)).not.toContain(wt)
|
|
})
|
|
|
|
it('never leaks raw git stderr in the dirty-tree error (SEC-M10)', async () => {
|
|
if (!gitAvailable) return
|
|
const { repo, wt } = await makeRepoWithWorktree()
|
|
await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8')
|
|
const res = await removeWorktree(repo, wt, { timeoutMs: TIMEOUT_MS })
|
|
expect(res.ok).toBe(false)
|
|
expect(res.error).toBeDefined()
|
|
expect(res.error).not.toContain('fatal:')
|
|
expect(res.error).not.toContain('error:')
|
|
expect(res.error).not.toContain(wt) // no path leak
|
|
})
|
|
|
|
it('refuses a locked worktree with 409 (unlock first; never auto -f -f)', async () => {
|
|
if (!gitAvailable) return
|
|
const { repo, wt } = await makeRepoWithWorktree()
|
|
await execFileP('git', ['worktree', 'lock', wt], { cwd: repo })
|
|
const res = await removeWorktree(repo, wt, { force: true, timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: false, status: 409 })
|
|
expect(res.error).toMatch(/lock/i)
|
|
expect(await worktreeList(repo)).toContain(wt)
|
|
})
|
|
|
|
it('matches a symlink alias via realpath and removes git\'s canonical path (M2)', async () => {
|
|
if (!gitAvailable) return
|
|
const { repo, wt } = await makeRepoWithWorktree()
|
|
const aliasParent = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-alias-'))
|
|
const alias = path.join(aliasParent, 'link-to-wt')
|
|
await fs.symlink(wt, alias)
|
|
// Passing the symlink still resolves to the same registered worktree.
|
|
const res = await removeWorktree(repo, alias, { timeoutMs: TIMEOUT_MS })
|
|
expect(res.ok).toBe(true)
|
|
expect(await worktreeList(repo)).not.toContain(wt)
|
|
})
|
|
})
|
|
|
|
// ── pruneWorktrees ──────────────────────────────────────────────────────────────
|
|
|
|
describe('pruneWorktrees', () => {
|
|
it('returns 404 for a non-git directory', async () => {
|
|
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-pruneplain-'))
|
|
const res = await pruneWorktrees(plain, { timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: false, status: 404 })
|
|
})
|
|
|
|
it('prunes a worktree whose folder was deleted out-of-band', async () => {
|
|
if (!gitAvailable) return
|
|
const { repo, wt } = await makeRepoWithWorktree()
|
|
// Simulate a user rm -rf'ing the worktree dir behind git's back.
|
|
await fs.rm(wt, { recursive: true, force: true })
|
|
const res = await pruneWorktrees(repo, { timeoutMs: TIMEOUT_MS })
|
|
expect(res.ok).toBe(true)
|
|
expect(Array.isArray(res.pruned)).toBe(true)
|
|
expect((res.pruned as string[]).length).toBeGreaterThanOrEqual(1)
|
|
// git no longer lists the stale worktree.
|
|
expect(await worktreeList(repo)).not.toContain(wt)
|
|
})
|
|
|
|
it('is idempotent — a clean repo prunes nothing', async () => {
|
|
if (!gitAvailable) return
|
|
const repo = await makeRepo()
|
|
const res = await pruneWorktrees(repo, { timeoutMs: TIMEOUT_MS })
|
|
expect(res).toMatchObject({ ok: true })
|
|
expect(res.pruned).toEqual([])
|
|
})
|
|
})
|