fix(gsd): syncWorktreeStateBack recurses into tasks/ subdirectory (#1678) (#1718)

Fixes #1678
This commit is contained in:
deseltrus 2026-03-21 15:46:53 +01:00 committed by GitHub
parent 57b92dee43
commit 24af556942
2 changed files with 215 additions and 9 deletions

View file

@ -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 */
}
}
}

View file

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