diff --git a/public/projects.ts b/public/projects.ts index 8f29a77..6e17f6d 100644 Binary files a/public/projects.ts and b/public/projects.ts differ diff --git a/src/http/worktrees.ts b/src/http/worktrees.ts index c395f22..5445382 100644 --- a/src/http/worktrees.ts +++ b/src/http/worktrees.ts @@ -9,7 +9,12 @@ import fs from 'node:fs/promises' import path from 'node:path' import { execFile } from 'node:child_process' 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) @@ -249,3 +254,137 @@ export async function createWorktree( 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] -- ` 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 { + 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 : ` 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 { + 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.' } + } +} diff --git a/src/server.ts b/src/server.ts index 3f6ed34..19cf0d8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -44,7 +44,7 @@ import { getGitLog } from './http/git-log.js' import { buildDigest, clampSince } from './http/digest.js' import { getPrStatus } from './http/gh.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 { detachWs, writeInput, setClientDims } from './session/session.js' import { loadSubscriptionStore } from './push/subscription-store.js' @@ -928,6 +928,58 @@ export function startServer(cfg: Config): { close(): Promise } { 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 + 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 + 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) ──── app.get('/config/ui', (_req, res) => { const uiConfig: UiConfig = { diff --git a/src/types.ts b/src/types.ts index f30f307..be39bd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -594,6 +594,26 @@ export interface CreateWorktreeResult { 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) ── */ /** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts). diff --git a/test/http/worktrees-remove.test.ts b/test/http/worktrees-remove.test.ts new file mode 100644 index 0000000..1d71bf3 --- /dev/null +++ b/test/http/worktrees-remove.test.ts @@ -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 { + 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', () => { + 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([]) + }) +}) diff --git a/test/integration/worktree.test.ts b/test/integration/worktree.test.ts index 3d58bbb..755974f 100644 --- a/test/integration/worktree.test.ts +++ b/test/integration/worktree.test.ts @@ -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 { + 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)', () => { diff --git a/test/worktree-form.test.ts b/test/worktree-form.test.ts index 5615201..c521793 100644 --- a/test/worktree-form.test.ts +++ b/test/worktree-form.test.ts @@ -11,7 +11,7 @@ */ 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) ────────────────────── class FakeTerminal { @@ -41,8 +41,14 @@ const mockMountPrChip = vi.fn(() => mockPrChipHandle) vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip })) // ── Import AFTER mocks ───────────────────────────────────────────────────────── -const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } = - await import('../public/projects.js') +const { + validateBranchNameClient, + renderNewWorktreeForm, + renderProjectDetail, + makeWorktreeRow, + confirmAndRemoveWorktree, + confirmAndPruneWorktrees, +} = await import('../public/projects.js') /* ── Helpers ───────────────────────────────────────────────────────────────── */ @@ -579,3 +585,226 @@ describe('renderProjectDetail — A4 Activity section', () => { expect(mockDiffHandle.destroy).toHaveBeenCalled() }) }) + +/* ── W4: makeWorktreeRow remove button ─────────────────────────────────────── */ + +function wt(overrides: Partial = {}): 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') + }) +})