From 24af5569427475b9e9bcf4845b394a0c5c554111 Mon Sep 17 00:00:00 2001 From: deseltrus <101901449+deseltrus@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:46:53 +0100 Subject: [PATCH] fix(gsd): syncWorktreeStateBack recurses into tasks/ subdirectory (#1678) (#1718) Fixes #1678 --- src/resources/extensions/gsd/auto-worktree.ts | 18 +- .../gsd/tests/worktree-sync-tasks.test.ts | 206 ++++++++++++++++++ 2 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index f6717c0c9..e20b2a80c 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -302,19 +302,19 @@ export function syncWorktreeStateBack( /* non-fatal */ } } else if (fileEntry.isDirectory() && fileEntry.name === "tasks") { - // Recurse into tasks/ to sync task-level summaries (#1678) + // Recurse into tasks/ subdirectory to sync task summaries (#1678). + // Without this, T01-SUMMARY.md etc. are silently dropped on + // worktree teardown because the loop only processes isFile() entries. const wtTasksDir = join(wtSliceDir, "tasks"); const mainTasksDir = join(mainSliceDir, "tasks"); + mkdirSync(mainTasksDir, { recursive: true }); try { - mkdirSync(mainTasksDir, { recursive: true }); - for (const taskEntry of readdirSync(wtTasksDir, { - withFileTypes: true, - })) { + for (const taskEntry of readdirSync(wtTasksDir, { withFileTypes: true })) { if (taskEntry.isFile() && taskEntry.name.endsWith(".md")) { - const src = join(wtTasksDir, taskEntry.name); - const dst = join(mainTasksDir, taskEntry.name); + const taskSrc = join(wtTasksDir, taskEntry.name); + const taskDst = join(mainTasksDir, taskEntry.name); try { - cpSync(src, dst, { force: true }); + cpSync(taskSrc, taskDst, { force: true }); synced.push( `milestones/${milestoneId}/slices/${sid}/tasks/${taskEntry.name}`, ); @@ -324,7 +324,7 @@ export function syncWorktreeStateBack( } } } catch { - /* non-fatal */ + /* non-fatal: tasks dir read failure */ } } } diff --git a/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts b/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts new file mode 100644 index 000000000..43d57c59e --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts @@ -0,0 +1,206 @@ +/** + * worktree-sync-tasks.test.ts — Regression test for #1678. + * + * Verifies that syncWorktreeStateBack() correctly syncs task summaries + * from the tasks/ subdirectory within each slice, not just slice-level files. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { syncWorktreeStateBack } from "../auto-worktree.ts"; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function makeTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), `gsd-sync-test-${prefix}-`)); +} + +function cleanup(...dirs: string[]): void { + for (const dir of dirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } + } +} + +function writeFile(dir: string, relativePath: string, content: string): void { + const fullPath = join(dir, relativePath); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content, "utf-8"); +} + +// ─── Tests ─────────────────────────────────────────────────────────── + +test("syncWorktreeStateBack copies task summaries from tasks/ subdirectory (#1678)", () => { + const mainBase = makeTempDir("main"); + const wtBase = makeTempDir("wt"); + const mid = "M001"; + + try { + // Set up worktree with milestone, slice, and task files + writeFile(wtBase, `.gsd/milestones/${mid}/${mid}-ROADMAP.md`, "# Roadmap\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/${mid}-SUMMARY.md`, "# Summary\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-PLAN.md`, "# Plan\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-SUMMARY.md`, "# Slice Summary\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-UAT.md`, "# UAT\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-PLAN.md`, "# Task 1 Plan\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`, "# Task 1 Summary\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/T02-PLAN.md`, "# Task 2 Plan\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/T02-SUMMARY.md`, "# Task 2 Summary\n"); + + // Set up main with empty .gsd + mkdirSync(join(mainBase, ".gsd"), { recursive: true }); + + // Run sync + const result = syncWorktreeStateBack(mainBase, wtBase, mid); + + // Verify milestone-level files synced + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/${mid}-ROADMAP.md`)), + "ROADMAP should be synced", + ); + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/${mid}-SUMMARY.md`)), + "SUMMARY should be synced", + ); + + // Verify slice-level files synced + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/S01-PLAN.md`)), + "S01-PLAN should be synced", + ); + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/S01-SUMMARY.md`)), + "S01-SUMMARY should be synced", + ); + + // Verify task-level files synced (THE BUG FIX) + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-PLAN.md`)), + "T01-PLAN should be synced (was dropped before fix)", + ); + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`)), + "T01-SUMMARY should be synced (was dropped before fix)", + ); + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T02-PLAN.md`)), + "T02-PLAN should be synced (was dropped before fix)", + ); + assert.ok( + existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T02-SUMMARY.md`)), + "T02-SUMMARY should be synced (was dropped before fix)", + ); + + // Verify task files appear in synced list + const taskSynced = result.synced.filter(p => p.includes("/tasks/")); + assert.ok( + taskSynced.length >= 4, + `Expected at least 4 task files in synced list, got ${taskSynced.length}: ${taskSynced.join(", ")}`, + ); + + // Verify content integrity + const t1Summary = readFileSync( + join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`), + "utf-8", + ); + assert.equal(t1Summary, "# Task 1 Summary\n"); + } finally { + cleanup(mainBase, wtBase); + } +}); + +test("syncWorktreeStateBack handles multiple slices with tasks (#1678)", () => { + const mainBase = makeTempDir("main"); + const wtBase = makeTempDir("wt"); + const mid = "M002"; + + try { + // Set up two slices with tasks + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-SUMMARY.md`, "# S01\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`, "# S01-T01\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S02/S02-SUMMARY.md`, "# S02\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S02/tasks/T01-SUMMARY.md`, "# S02-T01\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S02/tasks/T02-SUMMARY.md`, "# S02-T02\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S02/tasks/T03-SUMMARY.md`, "# S02-T03\n"); + + mkdirSync(join(mainBase, ".gsd"), { recursive: true }); + + const result = syncWorktreeStateBack(mainBase, wtBase, mid); + + // All task summaries from both slices should be synced + assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`))); + assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S02/tasks/T01-SUMMARY.md`))); + assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S02/tasks/T02-SUMMARY.md`))); + assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S02/tasks/T03-SUMMARY.md`))); + + // Verify content integrity across slices + assert.equal( + readFileSync(join(mainBase, `.gsd/milestones/${mid}/slices/S02/tasks/T03-SUMMARY.md`), "utf-8"), + "# S02-T03\n", + ); + } finally { + cleanup(mainBase, wtBase); + } +}); + +test("syncWorktreeStateBack handles slices without tasks/ directory", () => { + const mainBase = makeTempDir("main"); + const wtBase = makeTempDir("wt"); + const mid = "M003"; + + try { + // Slice with no tasks/ subdirectory (legitimate case: pre-planning) + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-RESEARCH.md`, "# Research\n"); + + mkdirSync(join(mainBase, ".gsd"), { recursive: true }); + + const result = syncWorktreeStateBack(mainBase, wtBase, mid); + + // Should sync the slice file without errors + assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/S01-RESEARCH.md`))); + // Should not have any task entries + const taskSynced = result.synced.filter(p => p.includes("/tasks/")); + assert.equal(taskSynced.length, 0); + } finally { + cleanup(mainBase, wtBase); + } +}); + +test("syncWorktreeStateBack ignores non-md files in tasks/", () => { + const mainBase = makeTempDir("main"); + const wtBase = makeTempDir("wt"); + const mid = "M004"; + + try { + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-PLAN.md`, "# Plan\n"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`, "# T01\n"); + // Non-md file should be ignored + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/.DS_Store`, "junk"); + writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/tasks/notes.txt`, "notes"); + + mkdirSync(join(mainBase, ".gsd"), { recursive: true }); + + const result = syncWorktreeStateBack(mainBase, wtBase, mid); + + // Only .md files should be synced + assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`))); + assert.ok(!existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/.DS_Store`))); + assert.ok(!existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/notes.txt`))); + } finally { + cleanup(mainBase, wtBase); + } +});