/** * 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 { 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 { const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo }) return stdout } // ── removeWorktree ────────────────────────────────────────────────────────────── describe('removeWorktree', { timeout: 30_000 }, () => { 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', { timeout: 30_000 }, () => { 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([]) }) })