Files
web-terminal/test/http/worktrees-remove.test.ts
Yaojia Wang cc811dd18d test: stop the suite flaking on real-git and real-PTY fixtures
`npm test` was failing 5-11 tests per run with the set shifting between runs,
which made it useless as a gate. Two independent causes, neither of them a
product defect.

1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12
   sequential `git` processes (init/config/commit/clone/push); the real-server
   ones boot a server and a shell. Alone they finish easily; under a full
   parallel run they do not, and the failure reads `Test timed out in 5000ms`.
   Gave those describes an explicit 30 s ceiling, and the real-PTY waits a
   named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected"
   handshake tests are left alone — there a timeout IS the assertion.
   (The same rebudgeting also touched the test files in the preceding commit.)

2. test/integration/server.test.ts cannot share the machine with the rest of
   the suite. It asserts on real prompt output within seconds, which is not
   achievable while ~8 workers saturate the box: it passed alone (27/27,
   repeatedly) and flaked in the full run. Raising the numbers further only
   moved the flake around, so this is fixed as a scheduling problem — `npm
   test` now runs two passes, `test:unit` (everything else, parallel) then
   `test:e2e` (that file on its own). `vitest run` still runs everything at
   once for anyone who wants that.

Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever
really went wrong in the body — the inner waits threw, control jumped to the
finally, close() blocked, and the case reported a bare timeout instead of its
own diagnosis. The root-causing above only became possible after making that
failure legible.

Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT
skipping here. PTY_AVAILABLE is true on this machine, so they were running and
failing on timing, not being gated out by a sandbox.

Verified: npm test green end to end across repeated runs (unit 78 files /
2147 tests, e2e 27).
2026-07-29 17:12:16 +02:00

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', { 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([])
})
})