diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index f6d379048..2ece198ed 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -2200,6 +2200,39 @@ export function deleteSlice(milestoneId: string, sliceId: string): void { }); } +export function deleteMilestone(milestoneId: string): void { + if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + transaction(() => { + currentDb!.prepare( + `DELETE FROM verification_evidence WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM quality_gates WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM tasks WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM slice_dependencies WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM slices WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM replan_history WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM assessments WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM artifacts WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM milestones WHERE id = :mid`, + ).run({ ":mid": milestoneId }); + }); +} + export function updateSliceFields(milestoneId: string, sliceId: string, fields: { title?: string; risk?: string; diff --git a/src/resources/extensions/gsd/milestone-actions.ts b/src/resources/extensions/gsd/milestone-actions.ts index 06562a893..51f1814ad 100644 --- a/src/resources/extensions/gsd/milestone-actions.ts +++ b/src/resources/extensions/gsd/milestone-actions.ts @@ -20,7 +20,8 @@ import { } from "./paths.js"; import { invalidateAllCaches } from "./cache.js"; import { loadQueueOrder, saveQueueOrder } from "./queue-order.js"; -import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js"; +import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js"; +import { removeWorktree } from "./worktree-manager.js"; import { logWarning } from "./workflow-logger.js"; // ─── Park ────────────────────────────────────────────────────────────────── @@ -110,6 +111,15 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean const mDir = resolveMilestonePath(basePath, milestoneId); if (!mDir || !existsSync(mDir)) return false; + try { + removeWorktree(basePath, milestoneId, { + branch: `milestone/${milestoneId}`, + deleteBranch: true, + }); + } catch (err) { + logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${(err as Error).message}`); + } + rmSync(mDir, { recursive: true, force: true }); // Prune from queue order if present @@ -118,6 +128,14 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean saveQueueOrder(basePath, order.filter(id => id !== milestoneId)); } + if (isDbAvailable()) { + try { + deleteMilestone(milestoneId); + } catch (err) { + logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${(err as Error).message}`); + } + } + invalidateAllCaches(); return true; } diff --git a/src/resources/extensions/gsd/tests/park-milestone.test.ts b/src/resources/extensions/gsd/tests/park-milestone.test.ts index 5d9cd4efd..442a1cb74 100644 --- a/src/resources/extensions/gsd/tests/park-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/park-milestone.test.ts @@ -3,10 +3,22 @@ import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; import { deriveState, invalidateStateCache, getActiveMilestoneId } from '../state.ts'; import { clearPathCache } from '../paths.ts'; import { parkMilestone, unparkMilestone, discardMilestone, isParked, getParkedReason } from '../milestone-actions.ts'; +import { + closeDatabase, + getMilestone, + getMilestoneSlices, + getSliceTasks, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../gsd-db.ts"; +import { createWorktree } from "../worktree-manager.ts"; @@ -60,9 +72,29 @@ function createMilestone(base: string, mid: string, opts?: { withRoadmap?: boole } function cleanup(base: string): void { + try { + closeDatabase(); + } catch { + // ignore + } rmSync(base, { recursive: true, force: true }); } +function run(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +function initGitRepo(base: string): void { + writeFileSync(join(base, "README.md"), "# test\n", "utf-8"); + writeFileSync(join(base, ".gsd", "STATE.md"), "# State\n", "utf-8"); + run("git init", base); + run("git config user.email test@test.com", base); + run("git config user.name Test", base); + run("git add .", base); + run('git commit -m "init"', base); + run("git branch -M main", base); +} + function clearCaches(): void { clearPathCache(); invalidateStateCache(); @@ -294,6 +326,38 @@ test('discardMilestone updates queue order', () => { } }); +test('discardMilestone removes DB rows, worktree, and milestone branch', () => { + const base = createFixtureBase(); + try { + createMilestone(base, 'M001', { withRoadmap: true }); + initGitRepo(base); + clearCaches(); + + assert.ok(openDatabase(join(base, '.gsd', 'gsd.db')), 'database opens'); + insertMilestone({ id: 'M001', title: 'Discard me', status: 'active' }); + insertSlice({ milestoneId: 'M001', id: 'S01', title: 'Only slice', status: 'pending' }); + insertTask({ milestoneId: 'M001', sliceId: 'S01', id: 'T01', title: 'Only task', status: 'pending' }); + + const wt = createWorktree(base, 'M001', { branch: 'milestone/M001' }); + assert.ok(existsSync(wt.path), 'worktree exists before discard'); + assert.ok(run('git branch', base).includes('milestone/M001'), 'milestone branch exists before discard'); + assert.ok(getMilestone('M001'), 'milestone exists in DB before discard'); + assert.equal(getMilestoneSlices('M001').length, 1, 'slice exists in DB before discard'); + assert.equal(getSliceTasks('M001', 'S01').length, 1, 'task exists in DB before discard'); + + const success = discardMilestone(base, 'M001'); + assert.ok(success, 'discardMilestone returns true'); + + assert.equal(getMilestone('M001'), null, 'milestone row removed from DB'); + assert.equal(getMilestoneSlices('M001').length, 0, 'slice rows removed from DB'); + assert.equal(getSliceTasks('M001', 'S01').length, 0, 'task rows removed from DB'); + assert.ok(!existsSync(wt.path), 'worktree removed after discard'); + assert.ok(!run('git branch', base).includes('milestone/M001'), 'milestone branch removed after discard'); + } finally { + cleanup(base); + } +}); + // ─── Test 12: All milestones parked → no active milestone ───────────── test('All milestones parked → no active', async () => { const base = createFixtureBase();