fix: detect worktree basePath in gsdRoot() to prevent escaping to project root (#3083)

When gsdRoot() is called with a basePath inside .gsd/worktrees/<name>/,
the git-root probe and walk-up logic can escape to the project root's .gsd
directory. This causes ensurePreconditions() to create slice directories
in the wrong location and deriveState() to read stale project-root state
instead of worktree-local state.

Add isInsideGsdWorktree() guard that detects the .gsd/worktrees/<name>/
pattern in the basePath before the git rev-parse probe runs. When detected,
return the worktree-local .gsd path immediately. Also check the
symlink-resolved path for the pattern (handles macOS /tmp -> /private/tmp).

Closes #2594

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:39:15 -04:00 committed by GitHub
parent a9d881ad8c
commit eb40f74cfe
2 changed files with 206 additions and 0 deletions

View file

@ -307,16 +307,58 @@ export function gsdRoot(basePath: string): string {
return result;
}
/**
* Detect if a path is inside a .gsd/worktrees/<name>/ structure.
*
* GSD auto-worktrees live at <project>/.gsd/worktrees/<milestoneId>/.
* When gsdRoot() is called with such a path, we must NOT walk up to the
* project root's .gsd each worktree manages its own .gsd state (#2594).
*
* Matches both forward-slash and platform-native separators to handle
* Windows paths (path.sep = '\\') and normalized Unix paths.
*/
function isInsideGsdWorktree(p: string): boolean {
// Match /.gsd/worktrees/<name> where <name> is the final segment or
// followed by a separator. The <name> segment must be non-empty.
const sepFwd = "/";
const sepNative = "\\";
const markers = [
`${sepFwd}.gsd${sepFwd}worktrees${sepFwd}`,
`${sepNative}.gsd${sepNative}worktrees${sepNative}`,
];
for (const marker of markers) {
const idx = p.indexOf(marker);
if (idx === -1) continue;
// Verify there's a non-empty worktree name after the marker
const afterMarker = p.slice(idx + marker.length);
// The name is everything up to the next separator (or end of string)
const nameEnd = afterMarker.search(/[/\\]/);
const name = nameEnd === -1 ? afterMarker : afterMarker.slice(0, nameEnd);
if (name.length > 0) return true;
}
return false;
}
function probeGsdRoot(rawBasePath: string): string {
// 1. Fast path — check the input path directly
const local = join(rawBasePath, ".gsd");
if (existsSync(local)) return local;
// 1b. Worktree guard (#2594) — if basePath is inside a .gsd/worktrees/<name>/
// structure, return the worktree-local .gsd path immediately. Without this,
// the git-root probe (step 2) or walk-up (step 3) escapes to the project
// root's .gsd, causing ensurePreconditions() and deriveState() to read/write
// state in the wrong location.
if (isInsideGsdWorktree(rawBasePath)) return local;
// Resolve symlinks so path comparisons work correctly across platforms
// (e.g. macOS /var → /private/var). Use rawBasePath as fallback if not resolvable.
let basePath: string;
try { basePath = realpathSync.native(rawBasePath); } catch { basePath = rawBasePath; }
// Also check the resolved path for the worktree pattern (macOS /tmp → /private/tmp)
if (basePath !== rawBasePath && isInsideGsdWorktree(basePath)) return local;
// 2. Git root anchor — used as both probe target and walk-up boundary
// Only walk if we're inside a git project — prevents escaping into
// unrelated filesystem territory when running outside any repo.

View file

@ -0,0 +1,164 @@
/**
* gsdroot-worktree-detection.test.ts Regression test for #2594.
*
* gsdRoot() must return the worktree's own .gsd directory when the basePath
* is inside a .gsd/worktrees/<name>/ structure, not walk up to the project
* root's .gsd via the git-root probe.
*
* The bug: when a git worktree lives at /project/.gsd/worktrees/M008/,
* probeGsdRoot() runs `git rev-parse --show-toplevel` which can return the
* main project root (not the worktree root) depending on git version and
* worktree setup. The walk-up then finds /project/.gsd and returns that
* instead of the worktree's own .gsd path.
*/
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { gsdRoot, _clearGsdRootCache } from "../paths.ts";
describe("gsdRoot() worktree detection (#2594)", () => {
let projectRoot: string;
let projectGsd: string;
beforeEach(() => {
_clearGsdRootCache();
// Create a temporary project with a git repo to simulate real conditions.
// realpathSync handles macOS /tmp -> /private/tmp.
projectRoot = realpathSync(mkdtempSync(join(tmpdir(), "gsdroot-wt-")));
projectGsd = join(projectRoot, ".gsd");
mkdirSync(projectGsd, { recursive: true });
// Initialize a git repo in the project root so git rev-parse works
spawnSync("git", ["init", "--initial-branch=main"], {
cwd: projectRoot,
stdio: "ignore",
});
spawnSync("git", ["config", "user.email", "test@test.com"], {
cwd: projectRoot,
stdio: "ignore",
});
spawnSync("git", ["config", "user.name", "Test"], {
cwd: projectRoot,
stdio: "ignore",
});
// Create an initial commit so we have a HEAD
writeFileSync(join(projectRoot, "README.md"), "# Test");
spawnSync("git", ["add", "."], { cwd: projectRoot, stdio: "ignore" });
spawnSync("git", ["commit", "-m", "init"], {
cwd: projectRoot,
stdio: "ignore",
});
});
afterEach(() => {
_clearGsdRootCache();
rmSync(projectRoot, { recursive: true, force: true });
});
test("returns worktree .gsd when basePath is a worktree with its own .gsd (fast path)", () => {
// Simulates a worktree that already had copyPlanningArtifacts() run,
// so it has its own .gsd/ directory.
const worktreeBase = join(projectGsd, "worktrees", "M008");
const worktreeGsd = join(worktreeBase, ".gsd");
mkdirSync(worktreeGsd, { recursive: true });
const result = gsdRoot(worktreeBase);
assert.equal(
result,
worktreeGsd,
`Expected worktree .gsd (${worktreeGsd}), got ${result}. ` +
"gsdRoot() should use the fast path for an existing worktree .gsd.",
);
});
test("returns worktree .gsd path (not project root .gsd) when worktree .gsd does not exist yet", () => {
// This is the core #2594 bug: the worktree directory exists but its .gsd
// subdirectory hasn't been created yet. Without the fix, probeGsdRoot()
// walks up from the worktree path, finds /project/.gsd, and returns it.
// With the fix, it detects the .gsd/worktrees/<name>/ pattern and returns
// the worktree-local .gsd path as the creation fallback.
const worktreeBase = join(projectGsd, "worktrees", "M008");
mkdirSync(worktreeBase, { recursive: true });
// NOTE: no .gsd/ inside worktreeBase
const result = gsdRoot(worktreeBase);
const expected = join(worktreeBase, ".gsd");
// Without the fix, this returns projectGsd (/project/.gsd) because the
// walk-up from worktreeBase finds it. With the fix, it returns the
// worktree-local path.
assert.notEqual(
result,
projectGsd,
"gsdRoot() must NOT return the project root .gsd when basePath is inside .gsd/worktrees/",
);
assert.equal(
result,
expected,
`Expected worktree-local .gsd (${expected}), got ${result}.`,
);
});
test("returns worktree .gsd when basePath is a real git worktree inside .gsd/worktrees/", () => {
// Create a real git worktree at .gsd/worktrees/M010
const worktreeName = "M010";
const worktreeBase = join(projectGsd, "worktrees", worktreeName);
// Use git worktree add to create a real worktree
const result = spawnSync(
"git",
["worktree", "add", "-b", `milestone/${worktreeName}`, worktreeBase],
{ cwd: projectRoot, encoding: "utf-8" },
);
if (result.status !== 0) {
// If git worktree add fails, skip the test gracefully
assert.ok(true, "Skipped: git worktree add not available");
return;
}
// The real git worktree exists at worktreeBase but has NO .gsd/ subdir yet
const gsdResult = gsdRoot(worktreeBase);
const expected = join(worktreeBase, ".gsd");
assert.notEqual(
gsdResult,
projectGsd,
"gsdRoot() must NOT escape to project root .gsd from inside a git worktree",
);
assert.equal(
gsdResult,
expected,
`Expected worktree-local .gsd (${expected}), got ${gsdResult}`,
);
// Cleanup worktree
spawnSync("git", ["worktree", "remove", "--force", worktreeBase], {
cwd: projectRoot,
stdio: "ignore",
});
});
test("still returns project .gsd for normal (non-worktree) basePath", () => {
const result = gsdRoot(projectRoot);
assert.equal(result, projectGsd);
});
test("still returns project .gsd for a subdirectory of the project", () => {
const subdir = join(projectRoot, "src", "lib");
mkdirSync(subdir, { recursive: true });
const result = gsdRoot(subdir);
assert.equal(
result,
projectGsd,
"Non-worktree subdirectories should still resolve to project .gsd",
);
});
});