From 142da7823a8e953ccbbbc04ad3889b4087ef5452 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:52:30 +0100 Subject: [PATCH] fix(gsd): prefer PREFERENCES.md in worktrees (#2796) Keep auto-worktree sync and initial seeding aligned with the repo's canonical preferences filename while retaining the lowercase legacy fallback for older repos and case-sensitive filesystems. --- src/resources/extensions/gsd/auto-worktree.ts | 49 +++++++++--- .../tests/preferences-worktree-sync.test.ts | 80 ++++++++++++++----- .../tests/worktree-preferences-sync.test.ts | 73 +++++++++++------ 3 files changed, 146 insertions(+), 56 deletions(-) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 1e9e78eb2..e94c04655 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -65,6 +65,8 @@ import { } from "./native-git-bridge.js"; const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); +const PROJECT_PREFERENCES_FILE = "PREFERENCES.md"; +const LEGACY_PROJECT_PREFERENCES_FILE = "preferences.md"; // ─── Shared Constants & Helpers ───────────────────────────────────────────── @@ -82,7 +84,7 @@ const ROOT_STATE_FILES = [ "QUEUE.md", "completed-units.json", "metrics.json", - // NOTE: preferences.md is intentionally NOT in ROOT_STATE_FILES. + // NOTE: project preferences are intentionally NOT in ROOT_STATE_FILES. // Forward-sync (main → worktree) is handled explicitly in syncGsdStateToWorktree(). // Back-sync (worktree → main) must NEVER overwrite the project root's copy // because the project root is authoritative for preferences (#2684). @@ -449,18 +451,25 @@ export function syncGsdStateToWorktree( } } - // Forward-sync preferences.md from project root to worktree (additive only). - // NOT in ROOT_STATE_FILES because syncWorktreeStateBack() must never overwrite - // the project root's preferences — the project root is authoritative (#2684). + // Forward-sync project preferences from project root to worktree (additive only). + // Prefer the canonical uppercase file name, but keep the legacy lowercase + // fallback so older repos still work on case-sensitive filesystems. { - const src = join(mainGsd, "preferences.md"); - const dst = join(wtGsd, "preferences.md"); - if (existsSync(src) && !existsSync(dst)) { - try { - cpSync(src, dst); - synced.push("preferences.md"); - } catch { - /* non-fatal */ + const worktreeHasPreferences = existsSync(join(wtGsd, PROJECT_PREFERENCES_FILE)) + || existsSync(join(wtGsd, LEGACY_PROJECT_PREFERENCES_FILE)); + if (!worktreeHasPreferences) { + for (const file of [PROJECT_PREFERENCES_FILE, LEGACY_PROJECT_PREFERENCES_FILE] as const) { + const src = join(mainGsd, file); + const dst = join(wtGsd, file); + if (existsSync(src)) { + try { + cpSync(src, dst); + synced.push(file); + } catch { + /* non-fatal */ + } + break; + } } } } @@ -995,11 +1004,25 @@ function copyPlanningArtifacts(srcBase: string, wtPath: string): void { "STATE.md", "KNOWLEDGE.md", "OVERRIDES.md", - "preferences.md", ]) { safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true }); } + // Seed canonical PREFERENCES.md when available; fall back to legacy lowercase. + if (existsSync(join(srcGsd, PROJECT_PREFERENCES_FILE))) { + safeCopy( + join(srcGsd, PROJECT_PREFERENCES_FILE), + join(dstGsd, PROJECT_PREFERENCES_FILE), + { force: true }, + ); + } else if (existsSync(join(srcGsd, LEGACY_PROJECT_PREFERENCES_FILE))) { + safeCopy( + join(srcGsd, LEGACY_PROJECT_PREFERENCES_FILE), + join(dstGsd, LEGACY_PROJECT_PREFERENCES_FILE), + { force: true }, + ); + } + // Shared WAL (R012): worktrees use the project root's DB directly. // No longer copy gsd.db into the worktree — the DB path resolver in // ensureDbOpen() detects the worktree location and opens the root DB. diff --git a/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts b/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts index 04a0fbd0f..c7f6828a6 100644 --- a/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +++ b/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts @@ -1,17 +1,19 @@ /** - * Regression tests for #2684: preferences.md must be included in both - * ROOT_STATE_FILES (sync) and copyPlanningArtifacts (initial seed). + * Regression tests for #2684 plus uppercase-preference normalization: + * preferences files are handled explicitly + * outside ROOT_STATE_FILES and prefer canonical PREFERENCES.md over the + * legacy lowercase fallback. * * Without this, post_unit_hooks and all preference-driven config silently * stop working inside auto-mode worktrees. */ import { test } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -test("#2684: preferences.md is NOT in ROOT_STATE_FILES (forward-only sync)", () => { +test("#2684: preferences files are NOT in ROOT_STATE_FILES (forward-only sync)", () => { const srcPath = join(import.meta.dirname, "..", "auto-worktree.ts"); const src = readFileSync(srcPath, "utf-8"); @@ -22,21 +24,23 @@ test("#2684: preferences.md is NOT in ROOT_STATE_FILES (forward-only sync)", () const arrayEnd = src.indexOf("] as const", arrayStart); const block = src.slice(arrayStart, arrayEnd); - // preferences.md must NOT be in ROOT_STATE_FILES — it is handled separately + // Project preferences must NOT be in ROOT_STATE_FILES — they are handled separately // in syncGsdStateToWorktree() (forward-only, additive). Including it in // ROOT_STATE_FILES would cause syncWorktreeStateBack() to overwrite the // authoritative project root copy (#2684). const entries = block.split("\n") .map(l => l.trim()) .filter(l => l.startsWith('"') && l.includes(".md")); - const hasPrefs = entries.some(l => l.includes("preferences.md")); + const hasPrefs = entries.some( + l => l.includes("PREFERENCES.md") || l.includes("preferences.md"), + ); assert.ok( !hasPrefs, - "preferences.md must NOT be in ROOT_STATE_FILES (back-sync would overwrite root)", + "preferences files must NOT be in ROOT_STATE_FILES (back-sync would overwrite root)", ); }); -test("#2684: copyPlanningArtifacts file list includes preferences.md", () => { +test("copyPlanningArtifacts prefers canonical PREFERENCES.md with lowercase fallback", () => { const srcPath = join(import.meta.dirname, "..", "auto-worktree.ts"); const src = readFileSync(srcPath, "utf-8"); @@ -45,15 +49,15 @@ test("#2684: copyPlanningArtifacts file list includes preferences.md", () => { assert.ok(fnIdx !== -1, "copyPlanningArtifacts function exists"); // Extract function body (up to the next top-level function) - const fnBody = src.slice(fnIdx, fnIdx + 1500); + const fnBody = src.slice(fnIdx, fnIdx + 2200); assert.ok( - fnBody.includes('"preferences.md"'), - "preferences.md should be in copyPlanningArtifacts file list", + fnBody.includes("PROJECT_PREFERENCES_FILE") && fnBody.includes("LEGACY_PROJECT_PREFERENCES_FILE"), + "copyPlanningArtifacts should prefer canonical PREFERENCES.md and retain lowercase fallback via the shared constants", ); }); -test("#2684: syncGsdStateToWorktree copies preferences.md", async () => { +test("syncGsdStateToWorktree copies canonical PREFERENCES.md", async () => { // Functional test: create a mock source and destination, call the sync const srcBase = mkdtempSync(join(tmpdir(), "gsd-wt-prefs-src-")); const dstBase = mkdtempSync(join(tmpdir(), "gsd-wt-prefs-dst-")); @@ -63,9 +67,9 @@ test("#2684: syncGsdStateToWorktree copies preferences.md", async () => { mkdirSync(dstGsd, { recursive: true }); try { - // Write a preferences.md in source + // Write a canonical PREFERENCES.md in source writeFileSync( - join(srcGsd, "preferences.md"), + join(srcGsd, "PREFERENCES.md"), "---\nversion: 1\n---\n\npost_unit_hooks:\n - name: notify\n command: echo done\n", ); @@ -73,16 +77,54 @@ test("#2684: syncGsdStateToWorktree copies preferences.md", async () => { const { syncGsdStateToWorktree } = await import("../auto-worktree.ts"); syncGsdStateToWorktree(srcBase, dstBase); - // Verify preferences.md was copied + // Verify PREFERENCES.md was copied assert.ok( - existsSync(join(dstGsd, "preferences.md")), - "preferences.md should be copied to worktree", + existsSync(join(dstGsd, "PREFERENCES.md")), + "PREFERENCES.md should be copied to worktree", ); - const content = readFileSync(join(dstGsd, "preferences.md"), "utf-8"); + const content = readFileSync(join(dstGsd, "PREFERENCES.md"), "utf-8"); assert.ok( content.includes("post_unit_hooks"), - "copied preferences.md should contain the hooks config", + "copied PREFERENCES.md should contain the hooks config", + ); + } finally { + rmSync(srcBase, { recursive: true, force: true }); + rmSync(dstBase, { recursive: true, force: true }); + } +}); + +test("syncGsdStateToWorktree falls back to legacy lowercase preferences.md", async () => { + const srcBase = mkdtempSync(join(tmpdir(), "gsd-wt-prefs-legacy-src-")); + const dstBase = mkdtempSync(join(tmpdir(), "gsd-wt-prefs-legacy-dst-")); + const srcGsd = join(srcBase, ".gsd"); + const dstGsd = join(dstBase, ".gsd"); + mkdirSync(srcGsd, { recursive: true }); + mkdirSync(dstGsd, { recursive: true }); + + try { + writeFileSync( + join(srcGsd, "preferences.md"), + "---\nversion: 1\n---\n\ngit:\n auto_push: true\n", + ); + + const { syncGsdStateToWorktree } = await import("../auto-worktree.ts"); + const result = syncGsdStateToWorktree(srcBase, dstBase); + + const copiedEntries = readdirSync(dstGsd) + .filter((name) => name === "PREFERENCES.md" || name === "preferences.md"); + + assert.ok( + copiedEntries.length === 1, + `expected exactly one preferences file in worktree, got ${copiedEntries.join(", ") || "(none)"}`, + ); + assert.ok( + copiedEntries[0] === "PREFERENCES.md" || copiedEntries[0] === "preferences.md", + "legacy fallback should still result in one readable preferences file", + ); + assert.ok( + result.synced.includes("preferences.md") || result.synced.includes("PREFERENCES.md"), + "legacy fallback copy should be reported in synced list", ); } finally { rmSync(srcBase, { recursive: true, force: true }); diff --git a/src/resources/extensions/gsd/tests/worktree-preferences-sync.test.ts b/src/resources/extensions/gsd/tests/worktree-preferences-sync.test.ts index 950421c45..691d58827 100644 --- a/src/resources/extensions/gsd/tests/worktree-preferences-sync.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-preferences-sync.test.ts @@ -1,11 +1,12 @@ /** * worktree-preferences-sync.test.ts — Regression test for #2684. * - * Verifies that preferences.md is seeded into auto-mode worktrees: + * Verifies that canonical PREFERENCES.md is seeded into auto-mode worktrees, + * while legacy lowercase preferences.md remains supported: * - * 1. copyPlanningArtifacts() copies preferences.md on initial worktree creation - * 2. syncGsdStateToWorktree() forward-syncs preferences.md (additive only) - * 3. syncWorktreeStateBack() does NOT overwrite project root preferences.md + * 1. syncGsdStateToWorktree() forward-syncs PREFERENCES.md (additive only) + * 2. syncGsdStateToWorktree() still accepts legacy lowercase preferences.md + * 3. syncWorktreeStateBack() does NOT overwrite project root PREFERENCES.md */ import test from "node:test"; @@ -15,6 +16,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from "node:fs"; @@ -56,35 +58,58 @@ const PREFS_CONTENT = [ ' - use: "frontend-design"', ].join("\n"); -test("#2684: syncGsdStateToWorktree forward-syncs preferences.md when missing from worktree", (t) => { +test("#2684: syncGsdStateToWorktree forward-syncs PREFERENCES.md when missing from worktree", (t) => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); t.after(() => cleanup(mainBase, wtBase)); - // Project root has preferences.md - writeFile(mainBase, ".gsd/preferences.md", PREFS_CONTENT); + // Project root has canonical PREFERENCES.md + writeFile(mainBase, ".gsd/PREFERENCES.md", PREFS_CONTENT); - // Worktree has .gsd/ but no preferences.md + // Worktree has .gsd/ but no preferences file mkdirSync(join(wtBase, ".gsd"), { recursive: true }); const result = syncGsdStateToWorktree(mainBase, wtBase); assert.ok( - existsSync(join(wtBase, ".gsd", "preferences.md")), - "preferences.md should be copied to worktree", + existsSync(join(wtBase, ".gsd", "PREFERENCES.md")), + "PREFERENCES.md should be copied to worktree", ); assert.equal( - readFileSync(join(wtBase, ".gsd", "preferences.md"), "utf-8"), + readFileSync(join(wtBase, ".gsd", "PREFERENCES.md"), "utf-8"), PREFS_CONTENT, - "preferences.md content should match source", + "PREFERENCES.md content should match source", ); assert.ok( - result.synced.includes("preferences.md"), - "preferences.md should appear in synced list", + result.synced.includes("PREFERENCES.md"), + "PREFERENCES.md should appear in synced list", ); }); -test("#2684: syncGsdStateToWorktree does NOT overwrite existing worktree preferences.md", (t) => { +test("syncGsdStateToWorktree still accepts legacy lowercase preferences.md", (t) => { + const mainBase = makeTempDir("main"); + const wtBase = makeTempDir("wt"); + t.after(() => cleanup(mainBase, wtBase)); + + writeFile(mainBase, ".gsd/preferences.md", PREFS_CONTENT); + mkdirSync(join(wtBase, ".gsd"), { recursive: true }); + + const result = syncGsdStateToWorktree(mainBase, wtBase); + + const copiedEntries = readdirSync(join(wtBase, ".gsd")) + .filter((name) => name === "PREFERENCES.md" || name === "preferences.md"); + + assert.ok( + copiedEntries.length === 1, + `expected exactly one preferences file in worktree, got ${copiedEntries.join(", ") || "(none)"}`, + ); + assert.ok( + result.synced.includes("preferences.md") || result.synced.includes("PREFERENCES.md"), + "legacy source should still appear in synced list", + ); +}); + +test("#2684: syncGsdStateToWorktree does NOT overwrite existing worktree preferences file", (t) => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); t.after(() => cleanup(mainBase, wtBase)); @@ -92,19 +117,19 @@ test("#2684: syncGsdStateToWorktree does NOT overwrite existing worktree prefere const rootPrefs = "# Root preferences\nold: true"; const wtPrefs = "# Worktree preferences\nmodified: true"; - writeFile(mainBase, ".gsd/preferences.md", rootPrefs); - writeFile(wtBase, ".gsd/preferences.md", wtPrefs); + writeFile(mainBase, ".gsd/PREFERENCES.md", rootPrefs); + writeFile(wtBase, ".gsd/PREFERENCES.md", wtPrefs); syncGsdStateToWorktree(mainBase, wtBase); assert.equal( - readFileSync(join(wtBase, ".gsd", "preferences.md"), "utf-8"), + readFileSync(join(wtBase, ".gsd", "PREFERENCES.md"), "utf-8"), wtPrefs, - "existing worktree preferences.md must not be overwritten", + "existing worktree PREFERENCES.md must not be overwritten", ); }); -test("#2684: syncWorktreeStateBack does NOT overwrite project root preferences.md", (t) => { +test("#2684: syncWorktreeStateBack does NOT overwrite project root PREFERENCES.md", (t) => { const mainBase = makeTempDir("main"); const wtBase = makeTempDir("wt"); const mid = "M001"; @@ -113,8 +138,8 @@ test("#2684: syncWorktreeStateBack does NOT overwrite project root preferences.m const rootPrefs = "# Root preferences\nauthoritative: true"; const wtPrefs = "# Worktree preferences\nstale-copy: true"; - writeFile(mainBase, ".gsd/preferences.md", rootPrefs); - writeFile(wtBase, ".gsd/preferences.md", wtPrefs); + writeFile(mainBase, ".gsd/PREFERENCES.md", rootPrefs); + writeFile(wtBase, ".gsd/PREFERENCES.md", wtPrefs); // Worktree needs at least a milestone dir for the function to proceed mkdirSync(join(wtBase, ".gsd", "milestones", mid), { recursive: true }); @@ -123,8 +148,8 @@ test("#2684: syncWorktreeStateBack does NOT overwrite project root preferences.m syncWorktreeStateBack(mainBase, wtBase, mid); assert.equal( - readFileSync(join(mainBase, ".gsd", "preferences.md"), "utf-8"), + readFileSync(join(mainBase, ".gsd", "PREFERENCES.md"), "utf-8"), rootPrefs, - "project root preferences.md must NOT be overwritten by worktree copy", + "project root PREFERENCES.md must NOT be overwritten by worktree copy", ); });