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.
This commit is contained in:
parent
b6e105b058
commit
142da7823a
3 changed files with 146 additions and 56 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue