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:
parent
be4037be90
commit
3522b54618
2 changed files with 167 additions and 7 deletions
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue