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:
Yaojia Wang
2026-07-12 21:44:23 +02:00
parent 1dd12b035a
commit 552f35c690
7 changed files with 801 additions and 5 deletions

Binary file not shown.

View File

@@ -9,7 +9,12 @@ import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { promisify } from 'node:util' 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) const execFileAsync = promisify(execFile)
@@ -249,3 +254,137 @@ export async function createWorktree(
return classifyWorktreeError(err) 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.' }
}
}

View File

@@ -44,7 +44,7 @@ import { getGitLog } from './http/git-log.js'
import { buildDigest, clampSince } from './http/digest.js' import { buildDigest, clampSince } from './http/digest.js'
import { getPrStatus } from './http/gh.js' import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.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 { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js' import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.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.' }) 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) ──── // ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => { app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = { const uiConfig: UiConfig = {

View File

@@ -594,6 +594,26 @@ export interface CreateWorktreeResult {
error?: string; 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) ── */ /* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts). /** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).

View File

@@ -0,0 +1,186 @@
/**
* 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([])
})
})

View File

@@ -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 ─────────────────────────────────────────────────────────── // ── GET /projects/diff ───────────────────────────────────────────────────────────
describe('GET /projects/diff (B1)', () => { describe('GET /projects/diff (B1)', () => {

View File

@@ -11,7 +11,7 @@
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ProjectDetail } from '../src/types.js' import type { ProjectDetail, WorktreeInfo } from '../src/types.js'
// ── Stub @xterm/xterm (transitively via preview-grid.ts) ────────────────────── // ── Stub @xterm/xterm (transitively via preview-grid.ts) ──────────────────────
class FakeTerminal { class FakeTerminal {
@@ -41,8 +41,14 @@ const mockMountPrChip = vi.fn(() => mockPrChipHandle)
vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip })) vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip }))
// ── Import AFTER mocks ───────────────────────────────────────────────────────── // ── Import AFTER mocks ─────────────────────────────────────────────────────────
const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } = const {
await import('../public/projects.js') validateBranchNameClient,
renderNewWorktreeForm,
renderProjectDetail,
makeWorktreeRow,
confirmAndRemoveWorktree,
confirmAndPruneWorktrees,
} = await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */ /* ── Helpers ───────────────────────────────────────────────────────────────── */
@@ -579,3 +585,226 @@ describe('renderProjectDetail — A4 Activity section', () => {
expect(mockDiffHandle.destroy).toHaveBeenCalled() expect(mockDiffHandle.destroy).toHaveBeenCalled()
}) })
}) })
/* ── W4: makeWorktreeRow remove button ─────────────────────────────────────── */
function wt(overrides: Partial<WorktreeInfo> = {}): WorktreeInfo {
return { path: '/repo/wt/feat', branch: 'feat', isMain: false, isCurrent: false, ...overrides }
}
describe('makeWorktreeRow — W4 remove button', () => {
const actions = { onRemove: vi.fn() }
it('adds a remove button for a non-main, non-locked worktree when actions are given', () => {
const row = makeWorktreeRow(wt(), actions)
expect(row.querySelector('.proj-wt-remove')).not.toBeNull()
})
it('omits the remove button when no actions are given (read-only row)', () => {
const row = makeWorktreeRow(wt())
expect(row.querySelector('.proj-wt-remove')).toBeNull()
})
it('never offers a remove button for the main worktree', () => {
const row = makeWorktreeRow(wt({ isMain: true, branch: 'main' }), actions)
expect(row.querySelector('.proj-wt-remove')).toBeNull()
expect(row.textContent).toContain('main')
})
it('never offers a remove button for a locked worktree (keeps the locked tag)', () => {
const row = makeWorktreeRow(wt({ locked: true }), actions)
expect(row.querySelector('.proj-wt-remove')).toBeNull()
expect(row.textContent).toContain('locked')
})
it('still offers a remove button for a prunable worktree', () => {
const row = makeWorktreeRow(wt({ prunable: true }), actions)
expect(row.querySelector('.proj-wt-remove')).not.toBeNull()
})
it('clicking the remove button calls onRemove with the worktree', () => {
const onRemove = vi.fn()
const w = wt()
const row = makeWorktreeRow(w, { onRemove })
;(row.querySelector('.proj-wt-remove') as HTMLButtonElement).click()
expect(onRemove).toHaveBeenCalledWith(w)
})
})
/* ── W4: renderProjectDetail prune button + remove wiring ──────────────────── */
describe('renderProjectDetail — W4 prune + remove wiring', () => {
const mainWt: WorktreeInfo = { path: '/repo', branch: 'main', isMain: true, isCurrent: true }
const featWt: WorktreeInfo = { path: '/repo/wt/feat', branch: 'feat', isMain: false, isCurrent: false }
const staleWt: WorktreeInfo = {
path: '/repo/wt/gone', branch: 'gone', isMain: false, isCurrent: false, prunable: true,
}
it('shows a "Prune stale worktrees" button when a worktree is prunable and a callback is wired', () => {
const root = renderProjectDetail(
makeDetail({ worktrees: [mainWt, staleWt] }),
makeHooks(),
{ ...makeCbs(), onPruneWorktrees: vi.fn() },
)
expect(root.querySelector('.proj-wt-prune')).not.toBeNull()
})
it('hides the prune button when no worktree is prunable', () => {
const root = renderProjectDetail(
makeDetail({ worktrees: [mainWt, featWt] }),
makeHooks(),
{ ...makeCbs(), onPruneWorktrees: vi.fn() },
)
expect(root.querySelector('.proj-wt-prune')).toBeNull()
})
it('hides the prune button when no prune callback is wired (pre-W4 callers)', () => {
const root = renderProjectDetail(makeDetail({ worktrees: [mainWt, staleWt] }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-wt-prune')).toBeNull()
})
it('threads onRemoveWorktree so clicking a row remove button passes the worktree path', () => {
const onRemoveWorktree = vi.fn()
const root = renderProjectDetail(
makeDetail({ worktrees: [mainWt, featWt] }),
makeHooks(),
{ ...makeCbs(), onRemoveWorktree },
)
const btn = root.querySelector('.proj-wt-remove') as HTMLButtonElement
expect(btn).not.toBeNull()
btn.click()
expect(onRemoveWorktree).toHaveBeenCalledWith('/repo/wt/feat')
})
it('clicking prune invokes onPruneWorktrees', () => {
const onPruneWorktrees = vi.fn()
const root = renderProjectDetail(
makeDetail({ worktrees: [mainWt, staleWt] }),
makeHooks(),
{ ...makeCbs(), onPruneWorktrees },
)
;(root.querySelector('.proj-wt-prune') as HTMLButtonElement).click()
expect(onPruneWorktrees).toHaveBeenCalled()
})
})
/* ── W4: confirmAndRemoveWorktree / confirmAndPruneWorktrees ───────────────── */
describe('confirmAndRemoveWorktree — confirm → (force) → refresh flow', () => {
it('confirms, DELETEs with force:false, and returns "removed" on success', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true }) })
vi.stubGlobal('fetch', mockFetch)
const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
expect(outcome).toBe('removed')
expect(mockFetch).toHaveBeenCalledWith(
'/projects/worktree',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ path: '/repo', worktreePath: '/repo/wt/feat', force: false }),
}),
)
})
it('on a 409 dirty refusal, asks a SECOND confirm then retries with force:true', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ ok: false, error: 'dirty' }) })
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) })
vi.stubGlobal('fetch', mockFetch)
const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
expect(outcome).toBe('removed')
expect(mockFetch).toHaveBeenCalledTimes(2)
// Second call carries force:true.
expect(mockFetch.mock.calls[1]?.[1]).toMatchObject({
body: JSON.stringify({ path: '/repo', worktreePath: '/repo/wt/feat', force: true }),
})
})
it('does NOT fetch when the user cancels the first confirm (no accidental delete)', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
expect(outcome).toBe('cancelled')
expect(mockFetch).not.toHaveBeenCalled()
})
it('does NOT force-delete when the user cancels the second (force) confirm', async () => {
vi.spyOn(window, 'confirm').mockReturnValueOnce(true).mockReturnValueOnce(false)
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ ok: false, error: 'dirty' }) })
vi.stubGlobal('fetch', mockFetch)
const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
expect(outcome).toBe('cancelled')
expect(mockFetch).toHaveBeenCalledTimes(1) // only the non-force attempt
})
it('reports a safe error via the onError callback and renders it via textContent (SEC-L3/H6)', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const mockFetch = vi
.fn()
.mockResolvedValue({ ok: false, status: 500, json: async () => ({ ok: false, error: 'boom' }) })
vi.stubGlobal('fetch', mockFetch)
const errEl = document.createElement('div')
const onError = (msg: string): void => { errEl.textContent = msg }
const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', onError)
expect(outcome).toBe('error')
expect(errEl.textContent).toBe('boom')
expect(errEl.querySelector('script')).toBeNull() // textContent, never innerHTML
})
it('reports a safe error on network failure (never throws)', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
const onError = vi.fn()
const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', onError)
expect(outcome).toBe('error')
expect(onError).toHaveBeenCalled()
})
})
describe('confirmAndPruneWorktrees', () => {
it('confirms, POSTs to the prune route, and returns "pruned" on success', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const mockFetch = vi
.fn()
.mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true, pruned: [] }) })
vi.stubGlobal('fetch', mockFetch)
const outcome = await confirmAndPruneWorktrees('/repo', vi.fn())
expect(outcome).toBe('pruned')
expect(mockFetch).toHaveBeenCalledWith(
'/projects/worktree/prune',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ path: '/repo' }) }),
)
})
it('does NOT fetch when the user cancels', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const outcome = await confirmAndPruneWorktrees('/repo', vi.fn())
expect(outcome).toBe('cancelled')
expect(mockFetch).not.toHaveBeenCalled()
})
it('reports a safe error via onError on failure', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 500, json: async () => ({ ok: false, error: 'nope' }),
}))
const onError = vi.fn()
const outcome = await confirmAndPruneWorktrees('/repo', onError)
expect(outcome).toBe('error')
expect(onError).toHaveBeenCalledWith('nope')
})
})