fix(worktree): sync root-level files and all milestone dirs on worktree teardown (#1794)

This commit is contained in:
Jeremy McSpadden 2026-03-21 12:39:37 -05:00 committed by GitHub
parent 79de78750f
commit afe5f58ea6
2 changed files with 302 additions and 11 deletions

View file

@ -229,8 +229,20 @@ export function syncGsdStateToWorktree(
* Called before milestone merge to ensure completion artifacts (SUMMARY, VALIDATION,
* updated ROADMAP) are visible from the project root (#1412).
*
* Only syncs .gsd/milestones/ content root-level files (DECISIONS, REQUIREMENTS, etc.)
* are handled by the merge itself.
* Syncs:
* 1. Root-level .gsd/ files (REQUIREMENTS, PROJECT, DECISIONS, KNOWLEDGE,
* OVERRIDES) the worktree's versions overwrite main's because the
* worktree is the authoritative execution context.
* 2. ALL milestone directories found in the worktree not just the
* current milestoneId. The complete-milestone unit may create artifacts
* for the *next* milestone (CONTEXT, ROADMAP, new requirements) which
* must survive worktree teardown.
*
* History: Originally only synced milestones/<milestoneId>/ and assumed
* root-level files would be carried by the squash merge. In practice,
* .gsd/ files are often untracked (gitignored or never committed), so the
* squash merge carries nothing. This caused next-milestone artifacts and
* updated REQUIREMENTS/PROJECT to be silently lost on teardown.
*/
export function syncWorktreeStateBack(
mainBasePath: string,
@ -250,10 +262,67 @@ export function syncWorktreeStateBack(
// Can't resolve — proceed with sync
}
const wtMilestoneDir = join(wtGsd, "milestones", milestoneId);
const mainMilestoneDir = join(mainGsd, "milestones", milestoneId);
if (!existsSync(wtGsd) || !existsSync(mainGsd)) return { synced };
if (!existsSync(wtMilestoneDir)) return { synced };
// ── 1. Sync root-level .gsd/ files back ──────────────────────────────
// The worktree is authoritative — complete-milestone updates REQUIREMENTS,
// PROJECT, etc. These must overwrite main's copies so they survive teardown.
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
];
for (const f of rootFiles) {
const src = join(wtGsd, f);
const dst = join(mainGsd, f);
if (existsSync(src)) {
try {
cpSync(src, dst, { force: true });
synced.push(f);
} catch {
/* non-fatal */
}
}
}
// ── 2. Sync ALL milestone directories ────────────────────────────────
// The complete-milestone unit may create next-milestone artifacts (e.g.
// M007 setup while closing M006). We must sync every milestone directory
// in the worktree, not just the current one.
const wtMilestonesDir = join(wtGsd, "milestones");
if (!existsSync(wtMilestonesDir)) return { synced };
try {
const wtMilestones = readdirSync(wtMilestonesDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && /^M\d{3}/.test(d.name))
.map((d) => d.name);
for (const mid of wtMilestones) {
syncMilestoneDir(wtGsd, mainGsd, mid, synced);
}
} catch {
/* non-fatal */
}
return { synced };
}
/**
* Sync a single milestone directory from worktree to main.
* Copies milestone-level .md files, slice-level files, and task summaries.
*/
function syncMilestoneDir(
wtGsd: string,
mainGsd: string,
mid: string,
synced: string[],
): void {
const wtMilestoneDir = join(wtGsd, "milestones", mid);
const mainMilestoneDir = join(mainGsd, "milestones", mid);
if (!existsSync(wtMilestoneDir)) return;
mkdirSync(mainMilestoneDir, { recursive: true });
// Sync milestone-level files (SUMMARY, VALIDATION, ROADMAP, CONTEXT)
@ -264,7 +333,7 @@ export function syncWorktreeStateBack(
const dst = join(mainMilestoneDir, entry.name);
try {
cpSync(src, dst, { force: true });
synced.push(`milestones/${milestoneId}/${entry.name}`);
synced.push(`milestones/${mid}/${entry.name}`);
} catch {
/* non-fatal */
}
@ -297,7 +366,7 @@ export function syncWorktreeStateBack(
try {
cpSync(src, dst, { force: true });
synced.push(
`milestones/${milestoneId}/slices/${sid}/${fileEntry.name}`,
`milestones/${mid}/slices/${sid}/${fileEntry.name}`,
);
} catch {
/* non-fatal */
@ -317,7 +386,7 @@ export function syncWorktreeStateBack(
try {
cpSync(taskSrc, taskDst, { force: true });
synced.push(
`milestones/${milestoneId}/slices/${sid}/tasks/${taskEntry.name}`,
`milestones/${mid}/slices/${sid}/tasks/${taskEntry.name}`,
);
} catch {
/* non-fatal */
@ -334,8 +403,6 @@ export function syncWorktreeStateBack(
/* non-fatal */
}
}
return { synced };
}
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────

View file

@ -16,9 +16,12 @@
* - No-op when milestoneId is null
* - Non-existent directories handled gracefully
* - syncWorktreeStateBack recurses into tasks/ subdirectory (#1678)
* - syncWorktreeStateBack syncs root-level .gsd/ files (REQUIREMENTS, PROJECT, etc.)
* - syncWorktreeStateBack syncs ALL milestone directories, not just the current one
* - syncWorktreeStateBack handles next-milestone artifacts created during completion
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@ -229,6 +232,227 @@ async function main(): Promise<void> {
}
}
// ─── 9. syncWorktreeStateBack syncs root-level .gsd/ files ──────────
console.log('\n=== 9. syncWorktreeStateBack syncs root-level files (REQUIREMENTS, PROJECT) ===');
{
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-back-root-main-'));
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-back-root-wt-'));
try {
mkdirSync(join(mainBase, '.gsd', 'milestones', 'M001'), { recursive: true });
mkdirSync(join(wtBase, '.gsd', 'milestones', 'M001'), { recursive: true });
// Main has original REQUIREMENTS and PROJECT
writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements\n## R001');
writeFileSync(join(mainBase, '.gsd', 'PROJECT.md'), '# Project\n## Milestone: M001');
// Worktree has updated versions (complete-milestone added M002 refs)
writeFileSync(join(wtBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements\n## R001\n## R002 — New req');
writeFileSync(join(wtBase, '.gsd', 'PROJECT.md'), '# Project\n## Milestone: M001\n## Milestone: M002');
writeFileSync(join(wtBase, '.gsd', 'KNOWLEDGE.md'), '# Knowledge\nLearned something.');
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001');
// Root-level files should be overwritten with worktree versions
const reqContent = readFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
assertTrue(
reqContent.includes('R002'),
'REQUIREMENTS.md updated with worktree content',
);
const projContent = readFileSync(join(mainBase, '.gsd', 'PROJECT.md'), 'utf-8');
assertTrue(
projContent.includes('M002'),
'PROJECT.md updated with worktree content',
);
assertTrue(
existsSync(join(mainBase, '.gsd', 'KNOWLEDGE.md')),
'KNOWLEDGE.md synced from worktree',
);
assertTrue(
synced.includes('REQUIREMENTS.md'),
'REQUIREMENTS.md appears in synced list',
);
assertTrue(
synced.includes('PROJECT.md'),
'PROJECT.md appears in synced list',
);
} finally {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
// ─── 10. syncWorktreeStateBack syncs ALL milestone directories ─────
console.log('\n=== 10. syncWorktreeStateBack syncs all milestone dirs, not just current ===');
{
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-back-all-main-'));
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-back-all-wt-'));
try {
mkdirSync(join(mainBase, '.gsd', 'milestones'), { recursive: true });
mkdirSync(join(wtBase, '.gsd', 'milestones'), { recursive: true });
// Worktree has M001 (current) AND M002 (next, created by complete-milestone)
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
mkdirSync(wtM001Dir, { recursive: true });
writeFileSync(join(wtM001Dir, 'M001-SUMMARY.md'), '# M001 Summary');
const wtM002Dir = join(wtBase, '.gsd', 'milestones', 'M002-abc123');
mkdirSync(wtM002Dir, { recursive: true });
writeFileSync(join(wtM002Dir, 'M002-abc123-CONTEXT.md'), '# M002 Context');
writeFileSync(join(wtM002Dir, 'M002-abc123-ROADMAP.md'), '# M002 Roadmap');
// Main has neither
assertTrue(
!existsSync(join(mainBase, '.gsd', 'milestones', 'M001')),
'M001 missing in main before sync',
);
assertTrue(
!existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123')),
'M002 missing in main before sync',
);
// Sync with milestoneId = M001 (the current milestone)
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001');
// M001 should be synced (current milestone — always synced)
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M001', 'M001-SUMMARY.md')),
'M001 SUMMARY synced to main',
);
// M002 should ALSO be synced (next milestone — the fix)
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123', 'M002-abc123-CONTEXT.md')),
'M002 CONTEXT synced to main (next-milestone fix)',
);
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123', 'M002-abc123-ROADMAP.md')),
'M002 ROADMAP synced to main (next-milestone fix)',
);
assertTrue(
synced.some((p) => p.includes('M002-abc123')),
'M002 appears in synced list',
);
} finally {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
// ─── 11. Full M006→M007 transition scenario ───────────────────────────
console.log('\n=== 11. complete-milestone creates next-milestone artifacts that survive sync ===');
{
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-transition-main-'));
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-transition-wt-'));
try {
mkdirSync(join(mainBase, '.gsd', 'milestones'), { recursive: true });
mkdirSync(join(wtBase, '.gsd', 'milestones'), { recursive: true });
// Main starts with M006 context + existing REQUIREMENTS
const mainM006 = join(mainBase, '.gsd', 'milestones', 'M006-589wvh');
mkdirSync(mainM006, { recursive: true });
writeFileSync(join(mainM006, 'M006-589wvh-CONTEXT.md'), '# M006 Context');
writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements\n## R001 through R089');
writeFileSync(join(mainBase, '.gsd', 'PROJECT.md'), '# Project\nMilestones: M001-M006');
// Worktree (M006 execution context) has:
// - M006 SUMMARY + VALIDATION (created by complete-milestone)
// - M007 setup (created by complete-milestone for next milestone)
// - Updated REQUIREMENTS with R090-R094
// - Updated PROJECT with M007
const wtM006 = join(wtBase, '.gsd', 'milestones', 'M006-589wvh');
mkdirSync(join(wtM006, 'slices', 'S01'), { recursive: true });
writeFileSync(join(wtM006, 'M006-589wvh-CONTEXT.md'), '# M006 Context');
writeFileSync(join(wtM006, 'M006-589wvh-SUMMARY.md'), '# M006 Complete');
writeFileSync(join(wtM006, 'M006-589wvh-VALIDATION.md'), '# Validated');
writeFileSync(join(wtM006, 'slices', 'S01', 'S01-SUMMARY.md'), '# S01 done');
const wtM007 = join(wtBase, '.gsd', 'milestones', 'M007-wortc8');
mkdirSync(wtM007, { recursive: true });
writeFileSync(join(wtM007, 'M007-wortc8-CONTEXT.md'), '# M007 Enterprise Security');
writeFileSync(join(wtM007, 'M007-wortc8-ROADMAP.md'), '# M007 Roadmap\n10 phases');
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)
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M006-589wvh');
// Verify M006 artifacts synced
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'M006-589wvh-SUMMARY.md')),
'M006 SUMMARY synced',
);
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'slices', 'S01', 'S01-SUMMARY.md')),
'M006 S01 SUMMARY synced',
);
// Verify M007 artifacts synced (the critical fix)
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M007-wortc8', 'M007-wortc8-CONTEXT.md')),
'M007 CONTEXT synced to main (next-milestone)',
);
assertTrue(
existsSync(join(mainBase, '.gsd', 'milestones', 'M007-wortc8', 'M007-wortc8-ROADMAP.md')),
'M007 ROADMAP synced to main (next-milestone)',
);
// Verify root-level files updated
const reqContent = readFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
assertTrue(
reqContent.includes('R090'),
'REQUIREMENTS.md has R090 from worktree',
);
const projContent = readFileSync(join(mainBase, '.gsd', 'PROJECT.md'), 'utf-8');
assertTrue(
projContent.includes('M007'),
'PROJECT.md has M007 from worktree',
);
} finally {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
// ─── 12. syncWorktreeStateBack no-op for root files that don't exist ──
console.log('\n=== 12. root files not in worktree are not created in main ===');
{
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-back-noroot-main-'));
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-back-noroot-wt-'));
try {
mkdirSync(join(mainBase, '.gsd', 'milestones', 'M001'), { recursive: true });
mkdirSync(join(wtBase, '.gsd', 'milestones', 'M001'), { recursive: true });
// Main has REQUIREMENTS, worktree does not
writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Original');
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001');
// Main's REQUIREMENTS should be untouched (worktree had nothing to sync)
const content = readFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
assertTrue(
content === '# Original',
'REQUIREMENTS.md unchanged when worktree has no copy',
);
assertTrue(
!synced.includes('REQUIREMENTS.md'),
'REQUIREMENTS.md not in synced list',
);
} finally {
rmSync(mainBase, { recursive: true, force: true });
rmSync(wtBase, { recursive: true, force: true });
}
}
report();
}