fix(gsd): discard milestone DB and worktree state (#4065)

This commit is contained in:
mastertyko 2026-04-13 18:04:38 +02:00 committed by GitHub
parent cf34383104
commit e6110976e7
3 changed files with 116 additions and 1 deletions

View file

@ -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;

View file

@ -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;
}

View file

@ -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();