From 3522b54618c0458cd00cd2a54527a0c965c20499 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 25 Mar 2026 02:07:22 -0400 Subject: [PATCH] fix(gsd): isInheritedRepo conflates ~/.gsd with project .gsd when git root is $HOME (#2398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user's home directory is a git repo (e.g. dotfile managers like yadm), isInheritedRepo() found ~/.gsd and concluded that subdirectories were part of an existing GSD project — loading the wrong project state. Extract isProjectGsd() to distinguish a project .gsd (symlink to external state, or legacy directory) from the global ~/.gsd state directory by comparing against the resolved GSD_HOME path. Fixes #2393 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/repo-identity.ts | 53 +++++++- .../gsd/tests/inherited-repo-home-dir.test.ts | 121 ++++++++++++++++++ 2 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/inherited-repo-home-dir.test.ts diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index f3e350801..597c8c63e 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -104,16 +104,17 @@ export function readRepoMeta(externalPath: string): RepoMeta | null { * Returns true when ALL of: * 1. basePath is inside a git repo (git rev-parse succeeds) * 2. The resolved git root is a proper ancestor of basePath - * 3. There is no `.gsd` directory at the git root (the parent project - * has not been initialised with GSD) + * 3. There is no *project* `.gsd` directory at the git root or any + * intermediate ancestor (the parent project has not been + * initialised with GSD) * * When true, the caller should run `git init` at basePath so that * `repoIdentity()` produces a hash unique to this directory, preventing * cross-project state leaks (#1639). * - * When the git root already has `.gsd`, the directory is a legitimate - * subdirectory of an existing GSD project — `cd src/ && /gsd` should - * still load the parent project's milestones. + * When the git root already has a project `.gsd`, the directory is a + * legitimate subdirectory of an existing GSD project — `cd src/ && /gsd` + * should still load the parent project's milestones. */ export function isInheritedRepo(basePath: string): boolean { try { @@ -124,12 +125,12 @@ export function isInheritedRepo(basePath: string): boolean { // The git root is a proper ancestor. Check whether it already has .gsd // (i.e. the parent project was initialised with GSD). - if (existsSync(join(root, ".gsd"))) return false; + if (isProjectGsd(join(root, ".gsd"))) return false; // Also walk up from basePath to the git root checking for .gsd let dir = normalizedBase; while (dir !== normalizedRoot && dir !== dirname(dir)) { - if (existsSync(join(dir, ".gsd"))) return false; + if (isProjectGsd(join(dir, ".gsd"))) return false; dir = dirname(dir); } @@ -139,6 +140,44 @@ export function isInheritedRepo(basePath: string): boolean { } } +/** + * Distinguish a *project* `.gsd` from the global `~/.gsd` state directory. + * + * A project `.gsd` is either: + * - A symlink to an external state directory (normal post-migration layout) + * - A legacy real directory that is NOT the global GSD home + * + * When the user's home directory is itself a git repo (e.g. dotfile managers), + * `~/.gsd` exists but is the global state directory — not a project `.gsd`. + * Treating it as a project `.gsd` would cause isInheritedRepo() to wrongly + * conclude that subdirectories are part of the home "project" (#2393). + */ +function isProjectGsd(gsdPath: string): boolean { + if (!existsSync(gsdPath)) return false; + + try { + const stat = lstatSync(gsdPath); + + // Symlinks are always project .gsd (created by ensureGsdSymlink). + if (stat.isSymbolicLink()) return true; + + // For real directories, check that this isn't the global GSD home. + // Recompute gsdHome dynamically so env overrides (GSD_HOME) are + // picked up at call time, not just at module load time. + if (stat.isDirectory()) { + const currentGsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); + const normalizedGsdPath = canonicalizeExistingPath(gsdPath); + const normalizedGsdHome = canonicalizeExistingPath(currentGsdHome); + if (normalizedGsdPath === normalizedGsdHome) return false; + return true; + } + } catch { + // lstat failed — treat as no .gsd present + } + + return false; +} + // ─── Repo Identity ────────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/tests/inherited-repo-home-dir.test.ts b/src/resources/extensions/gsd/tests/inherited-repo-home-dir.test.ts new file mode 100644 index 000000000..e201ffe5f --- /dev/null +++ b/src/resources/extensions/gsd/tests/inherited-repo-home-dir.test.ts @@ -0,0 +1,121 @@ +/** + * inherited-repo-home-dir.test.ts — Regression test for #2393. + * + * When the user's home directory IS a git repo (common with dotfile + * managers like yadm), isInheritedRepo() must not treat ~/.gsd (the + * global GSD state directory) as a project .gsd belonging to the home + * repo. Without the fix, isInheritedRepo() returns false for project + * subdirectories because it sees ~/.gsd and concludes the parent repo + * has already been initialised with GSD — causing the wrong project + * state to be loaded. + */ + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + realpathSync, + symlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; + +import { isInheritedRepo } from "../repo-identity.ts"; + +function run(cmd: string, args: string[], cwd: string): string { + return execFileSync(cmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); +} + +describe("isInheritedRepo when git root is HOME (#2393)", () => { + let fakeHome: string; + let stateDir: string; + let origGsdHome: string | undefined; + let origGsdStateDir: string | undefined; + + beforeEach(() => { + // Create a fake HOME that is itself a git repo (dotfile manager scenario). + fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-home-repo-"))); + run("git", ["init", "-b", "main"], fakeHome); + run("git", ["config", "user.name", "Test"], fakeHome); + run("git", ["config", "user.email", "test@example.com"], fakeHome); + writeFileSync(join(fakeHome, ".bashrc"), "# dotfiles\n", "utf-8"); + run("git", ["add", ".bashrc"], fakeHome); + run("git", ["commit", "-m", "init dotfiles"], fakeHome); + + // Create a plain ~/.gsd directory at fakeHome — this simulates the + // global GSD home directory, NOT a project .gsd. + mkdirSync(join(fakeHome, ".gsd", "projects"), { recursive: true }); + + // Save and override env. Point GSD_HOME at fakeHome/.gsd so the + // function recognizes it as the global state directory. + origGsdHome = process.env.GSD_HOME; + origGsdStateDir = process.env.GSD_STATE_DIR; + process.env.GSD_HOME = join(fakeHome, ".gsd"); + stateDir = mkdtempSync(join(tmpdir(), "gsd-state-")); + process.env.GSD_STATE_DIR = stateDir; + }); + + afterEach(() => { + if (origGsdHome !== undefined) process.env.GSD_HOME = origGsdHome; + else delete process.env.GSD_HOME; + if (origGsdStateDir !== undefined) process.env.GSD_STATE_DIR = origGsdStateDir; + else delete process.env.GSD_STATE_DIR; + + rmSync(fakeHome, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + }); + + test("subdirectory of home-as-git-root is detected as inherited even when ~/.gsd exists", () => { + // Create a project directory inside fake HOME + const projectDir = join(fakeHome, "projects", "my-app"); + mkdirSync(projectDir, { recursive: true }); + + // The bug: isInheritedRepo sees ~/.gsd and returns false, thinking + // the home repo is a legitimate GSD project. It should return true + // because ~/.gsd is the global state dir, not a project .gsd. + assert.strictEqual( + isInheritedRepo(projectDir), + true, + "project inside home-as-git-root must be detected as inherited repo, " + + "even when ~/.gsd (global state dir) exists", + ); + }); + + test("subdirectory with a real project .gsd symlink at git root is NOT inherited", () => { + // Simulate a legitimately initialised GSD project at the home repo root: + // .gsd is a symlink to an external state directory. + const externalState = join(stateDir, "projects", "home-project"); + mkdirSync(externalState, { recursive: true }); + const gsdDir = join(fakeHome, ".gsd"); + + // Remove the plain directory and replace with a symlink (real project .gsd) + rmSync(gsdDir, { recursive: true, force: true }); + symlinkSync(externalState, gsdDir); + + const projectDir = join(fakeHome, "projects", "my-app"); + mkdirSync(projectDir, { recursive: true }); + + // When .gsd at root IS a project symlink, subdirectories are legitimate children + assert.strictEqual( + isInheritedRepo(projectDir), + false, + "subdirectory of a legitimately-initialised GSD project should NOT be inherited", + ); + }); + + test("home-as-git-root itself is never inherited", () => { + assert.strictEqual( + isInheritedRepo(fakeHome), + false, + "the git root itself is never inherited", + ); + }); +});