Merge pull request #3682 from Tibsfox/fix/sync-worktree-skip-current-milestone

fix(gsd): skip current milestone in syncWorktreeStateBack to prevent merge conflicts
This commit is contained in:
Jeremy McSpadden 2026-04-07 07:06:14 -05:00 committed by GitHub
commit 764d8ff466
4 changed files with 97 additions and 26 deletions

View file

@ -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) {

View file

@ -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',
)
})
})

View file

@ -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)',

View file

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