diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d2420257f..29cab5b97 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -770,6 +770,9 @@ export function syncWorktreeStateBack( .map((d) => d.name); for (const mid of wtMilestones) { + // Skip the current milestone being merged — its files are already in the + // milestone branch and would conflict with the squash merge (#3641). + if (mid === milestoneId) continue; syncMilestoneDir(wtGsd, mainGsd, mid, synced); } } catch (err) { diff --git a/src/resources/extensions/gsd/tests/sync-worktree-skip-current.test.ts b/src/resources/extensions/gsd/tests/sync-worktree-skip-current.test.ts new file mode 100644 index 000000000..9b0070cb1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/sync-worktree-skip-current.test.ts @@ -0,0 +1,65 @@ +/** + * Regression test for #3641 — syncWorktreeStateBack skips current milestone + * + * When syncing worktree state back to main, the current milestone being + * merged should be skipped. Its files are already in the milestone branch + * and copying them back would conflict with the squash merge. + * + * The fix adds a `mid === milestoneId` skip guard inside the milestone + * iteration loop in syncWorktreeStateBack. + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const src = readFileSync( + resolve(process.cwd(), 'src', 'resources', 'extensions', 'gsd', 'auto-worktree.ts'), + 'utf-8', +) + +describe('syncWorktreeStateBack skips current milestone (#3641)', () => { + it('syncWorktreeStateBack function exists', () => { + assert.ok( + src.includes('function syncWorktreeStateBack('), + 'syncWorktreeStateBack function must be defined', + ) + }) + + it('mid === milestoneId skip guard exists in the milestone loop', () => { + // Find syncWorktreeStateBack + const fnStart = src.indexOf('function syncWorktreeStateBack(') + assert.ok(fnStart !== -1) + + // Get a reasonable portion of the function + const fnBlock = src.slice(fnStart, fnStart + 3000) + + // Find the for loop iterating milestones + const loopIdx = fnBlock.indexOf('for (const mid of wtMilestones)') + assert.ok(loopIdx !== -1, 'milestone iteration loop must exist') + + // After the loop, there should be the skip guard + const loopBody = fnBlock.slice(loopIdx, loopIdx + 300) + assert.ok( + loopBody.includes('mid === milestoneId'), + 'mid === milestoneId skip guard must exist inside the milestone loop', + ) + assert.ok( + loopBody.includes('continue'), + 'skip guard must use continue to skip the current milestone', + ) + }) + + it('syncMilestoneDir is still called for non-current milestones', () => { + const fnStart = src.indexOf('function syncWorktreeStateBack(') + assert.ok(fnStart !== -1) + + const fnBlock = src.slice(fnStart, fnStart + 3000) + + assert.ok( + fnBlock.includes('syncMilestoneDir('), + 'syncMilestoneDir must still be called for other milestones', + ) + }) +}) diff --git a/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts b/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts index f50d9df7b..57ebe3740 100644 --- a/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts @@ -221,7 +221,8 @@ describe('worktree-sync-milestones', async () => { try { // Build worktree milestone structure with slice-level and task-level files - const wtSliceDir = join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S01'); + // Use M002 as the milestone to sync, M001 as the "current" being merged (skipped) + const wtSliceDir = join(wtBase, '.gsd', 'milestones', 'M002', 'slices', 'S01'); const wtTasksDir = join(wtSliceDir, 'tasks'); mkdirSync(wtTasksDir, { recursive: true }); writeFileSync(join(wtSliceDir, 'S01-SUMMARY.md'), '# S01 Summary'); @@ -229,11 +230,12 @@ describe('worktree-sync-milestones', async () => { writeFileSync(join(wtTasksDir, 'T02-SUMMARY.md'), '# T02 Summary'); // Main project root starts with only the milestone directory (no slices yet) - mkdirSync(join(mainBase, '.gsd', 'milestones', 'M001'), { recursive: true }); + mkdirSync(join(mainBase, '.gsd', 'milestones', 'M002'), { recursive: true }); + // Pass M001 as milestoneId (the one being merged/skipped), M002 should still sync const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001'); - const mainSliceDir = join(mainBase, '.gsd', 'milestones', 'M001', 'slices', 'S01'); + const mainSliceDir = join(mainBase, '.gsd', 'milestones', 'M002', 'slices', 'S01'); const mainTasksDir = join(mainSliceDir, 'tasks'); assert.ok( @@ -341,16 +343,16 @@ describe('worktree-sync-milestones', async () => { 'M002 missing in main before sync', ); - // Sync with milestoneId = M001 (the current milestone) + // Sync with milestoneId = M001 (the current milestone being merged — skipped) const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001'); - // M001 should be synced (current milestone — always synced) + // M001 should be SKIPPED (current milestone being merged — #3641) assert.ok( - existsSync(join(mainBase, '.gsd', 'milestones', 'M001', 'M001-SUMMARY.md')), - 'M001 SUMMARY synced to main', + !existsSync(join(mainBase, '.gsd', 'milestones', 'M001', 'M001-SUMMARY.md')), + 'M001 SUMMARY NOT synced (current milestone skipped to prevent merge conflicts)', ); - // M002 should ALSO be synced (next milestone — the fix) + // M002 should be synced (other milestone — not skipped) assert.ok( existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123', 'M002-abc123-CONTEXT.md')), 'M002 CONTEXT synced to main (next-milestone fix)', @@ -407,20 +409,17 @@ describe('worktree-sync-milestones', async () => { writeFileSync(join(wtBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements\n## R001-R089\n## R090 — SCIM\n## R091 — WebAuthn'); writeFileSync(join(wtBase, '.gsd', 'PROJECT.md'), '# Project\nMilestones: M001-M007'); - // Sync with milestoneId = M006 (the completing milestone) + // Sync with milestoneId = M006 (the completing milestone — skipped by sync) const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M006-589wvh'); - // Verify M006 artifacts synced + // M006 is the current milestone being merged — it should be SKIPPED (#3641) + // Its files are already in the milestone branch and would conflict with squash merge. assert.ok( - existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'M006-589wvh-SUMMARY.md')), - 'M006 SUMMARY synced', - ); - assert.ok( - existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'slices', 'S01', 'S01-SUMMARY.md')), - 'M006 S01 SUMMARY synced', + !existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'M006-589wvh-SUMMARY.md')), + 'M006 SUMMARY NOT synced (current milestone skipped)', ); - // Verify M007 artifacts synced (the critical fix) + // Verify M007 artifacts synced (the critical fix — other milestones still sync) assert.ok( existsSync(join(mainBase, '.gsd', 'milestones', 'M007-wortc8', 'M007-wortc8-CONTEXT.md')), 'M007 CONTEXT synced to main (next-milestone)', diff --git a/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts b/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts index 43d57c59e..65717415c 100644 --- a/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-sync-tasks.test.ts @@ -47,7 +47,8 @@ function writeFile(dir: string, relativePath: string, content: string): void { test("syncWorktreeStateBack copies task summaries from tasks/ subdirectory (#1678)", () => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); - const mid = "M001"; + const currentMid = "M000"; // milestone being merged (skipped by sync) + const mid = "M001"; // other milestone that should be synced try { // Set up worktree with milestone, slice, and task files @@ -64,8 +65,8 @@ test("syncWorktreeStateBack copies task summaries from tasks/ subdirectory (#167 // Set up main with empty .gsd mkdirSync(join(mainBase, ".gsd"), { recursive: true }); - // Run sync - const result = syncWorktreeStateBack(mainBase, wtBase, mid); + // Run sync — currentMid is skipped, mid (M001) should be synced + const result = syncWorktreeStateBack(mainBase, wtBase, currentMid); // Verify milestone-level files synced assert.ok( @@ -126,7 +127,8 @@ test("syncWorktreeStateBack copies task summaries from tasks/ subdirectory (#167 test("syncWorktreeStateBack handles multiple slices with tasks (#1678)", () => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); - const mid = "M002"; + const currentMid = "M000"; // milestone being merged (skipped) + const mid = "M002"; // other milestone that should be synced try { // Set up two slices with tasks @@ -139,7 +141,7 @@ test("syncWorktreeStateBack handles multiple slices with tasks (#1678)", () => { mkdirSync(join(mainBase, ".gsd"), { recursive: true }); - const result = syncWorktreeStateBack(mainBase, wtBase, mid); + const result = syncWorktreeStateBack(mainBase, wtBase, currentMid); // All task summaries from both slices should be synced assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`))); @@ -160,7 +162,8 @@ test("syncWorktreeStateBack handles multiple slices with tasks (#1678)", () => { test("syncWorktreeStateBack handles slices without tasks/ directory", () => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); - const mid = "M003"; + const currentMid = "M000"; // milestone being merged (skipped) + const mid = "M003"; // other milestone that should be synced try { // Slice with no tasks/ subdirectory (legitimate case: pre-planning) @@ -168,7 +171,7 @@ test("syncWorktreeStateBack handles slices without tasks/ directory", () => { mkdirSync(join(mainBase, ".gsd"), { recursive: true }); - const result = syncWorktreeStateBack(mainBase, wtBase, mid); + const result = syncWorktreeStateBack(mainBase, wtBase, currentMid); // Should sync the slice file without errors assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/S01-RESEARCH.md`))); @@ -183,7 +186,8 @@ test("syncWorktreeStateBack handles slices without tasks/ directory", () => { test("syncWorktreeStateBack ignores non-md files in tasks/", () => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); - const mid = "M004"; + const currentMid = "M000"; // milestone being merged (skipped) + const mid = "M004"; // other milestone that should be synced try { writeFile(wtBase, `.gsd/milestones/${mid}/slices/S01/S01-PLAN.md`, "# Plan\n"); @@ -194,7 +198,7 @@ test("syncWorktreeStateBack ignores non-md files in tasks/", () => { mkdirSync(join(mainBase, ".gsd"), { recursive: true }); - const result = syncWorktreeStateBack(mainBase, wtBase, mid); + const result = syncWorktreeStateBack(mainBase, wtBase, currentMid); // Only .md files should be synced assert.ok(existsSync(join(mainBase, `.gsd/milestones/${mid}/slices/S01/tasks/T01-SUMMARY.md`)));