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:
@@ -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> = {}): 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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user