fix(gsd): isInheritedRepo conflates ~/.gsd with project .gsd when git root is $HOME (#2398)

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-25 02:07:22 -04:00 committed by GitHub
parent be4037be90
commit 3522b54618
2 changed files with 167 additions and 7 deletions

View file

@ -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 ──────────────────────────────────────────────────────────
/**

View file

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