From ce23da67184145a98d07e85d50e2cf686fc3444a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Thu, 26 Mar 2026 20:04:40 -0600 Subject: [PATCH] fix: write milestone reports to project root instead of worktree (#2778) * fix: write milestone reports to project root instead of worktree During worktree isolation, s.basePath points to the temporary worktree directory. Reports written there are silently lost when the worktree is cleaned up. Use s.originalBasePath (falling back to s.basePath when not in a worktree) so reports persist in the actual project directory. Closes #2751 Co-Authored-By: Claude Opus 4.6 (1M context) * test: add regression test for milestone report path resolution Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto/phases.ts | 21 ++++++-- .../gsd/tests/milestone-report-path.test.ts | 51 +++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/milestone-report-path.test.ts diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 4ef9ce1c1..c785bf2c0 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -36,6 +36,15 @@ import { writeUnitRuntimeRecord } from "../unit-runtime.js"; // ─── generateMilestoneReport ────────────────────────────────────────────────── +/** + * Resolve the base path for milestone reports. + * Prefers originalBasePath (project root) over basePath (which may be a worktree). + * Exported for testing as _resolveReportBasePath. + */ +export function _resolveReportBasePath(s: Pick): string { + return s.originalBasePath || s.basePath; +} + /** * Generate and write an HTML milestone report snapshot. * Extracted from the milestone-transition block in autoLoop. @@ -50,13 +59,15 @@ async function generateMilestoneReport( const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "../reports.js"); const { basename } = await import("node:path"); - const snapData = await loadVisualizerData(s.basePath); + const reportBasePath = _resolveReportBasePath(s); + + const snapData = await loadVisualizerData(reportBasePath); const completedMs = snapData.milestones.find( (m: { id: string }) => m.id === milestoneId, ); const msTitle = completedMs?.title ?? milestoneId; const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; - const projName = basename(s.basePath); + const projName = basename(reportBasePath); const doneSlices = snapData.milestones.reduce( (acc: number, m: { slices: { done: boolean }[] }) => acc + m.slices.filter((sl: { done: boolean }) => sl.done).length, @@ -67,10 +78,10 @@ async function generateMilestoneReport( 0, ); const outPath = writeReportSnapshot({ - basePath: s.basePath, + basePath: reportBasePath, html: generateHtmlReport(snapData, { projectName: projName, - projectPath: s.basePath, + projectPath: reportBasePath, gsdVersion, milestoneId, indexRelPath: "index.html", @@ -79,7 +90,7 @@ async function generateMilestoneReport( milestoneTitle: msTitle, kind: "milestone", projectName: projName, - projectPath: s.basePath, + projectPath: reportBasePath, gsdVersion, totalCost: snapData.totals?.cost ?? 0, totalTokens: snapData.totals?.tokens.total ?? 0, diff --git a/src/resources/extensions/gsd/tests/milestone-report-path.test.ts b/src/resources/extensions/gsd/tests/milestone-report-path.test.ts new file mode 100644 index 000000000..8ab7c1571 --- /dev/null +++ b/src/resources/extensions/gsd/tests/milestone-report-path.test.ts @@ -0,0 +1,51 @@ +/** + * milestone-report-path.test.ts — Regression test for milestone report path resolution. + * + * When running in a worktree, milestone reports must be written to the + * original project root (originalBasePath), not the worktree path (basePath). + * + * Covers: _resolveReportBasePath from auto/phases.ts + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { _resolveReportBasePath } from "../auto/phases.ts"; + +describe("_resolveReportBasePath", () => { + test("uses originalBasePath when set (worktree scenario)", () => { + const session = { + originalBasePath: "/projects/my-app", + basePath: "/projects/my-app/.claude/worktrees/agent-abc123", + }; + + assert.equal(_resolveReportBasePath(session), "/projects/my-app"); + }); + + test("falls back to basePath when originalBasePath is empty", () => { + const session = { + originalBasePath: "", + basePath: "/projects/my-app", + }; + + assert.equal(_resolveReportBasePath(session), "/projects/my-app"); + }); + + test("falls back to basePath when originalBasePath is undefined", () => { + const session = { + originalBasePath: undefined as unknown as string, + basePath: "/projects/my-app", + }; + + assert.equal(_resolveReportBasePath(session), "/projects/my-app"); + }); + + test("uses originalBasePath even when basePath differs", () => { + const session = { + originalBasePath: "/home/user/repo", + basePath: "/tmp/worktree-xyz", + }; + + assert.equal(_resolveReportBasePath(session), "/home/user/repo"); + }); +});