From eb40f74cfee7adcb95ecc432a16d921e4280a05d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:39:15 -0400 Subject: [PATCH] fix: detect worktree basePath in gsdRoot() to prevent escaping to project root (#3083) When gsdRoot() is called with a basePath inside .gsd/worktrees//, 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// 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 --- src/resources/extensions/gsd/paths.ts | 42 +++++ .../tests/gsdroot-worktree-detection.test.ts | 164 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/gsdroot-worktree-detection.test.ts diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index ccd3c59f6..1cdfc0334 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -307,16 +307,58 @@ export function gsdRoot(basePath: string): string { return result; } +/** + * Detect if a path is inside a .gsd/worktrees// structure. + * + * GSD auto-worktrees live at /.gsd/worktrees//. + * 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/ where is the final segment or + // followed by a separator. The 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// + // 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. diff --git a/src/resources/extensions/gsd/tests/gsdroot-worktree-detection.test.ts b/src/resources/extensions/gsd/tests/gsdroot-worktree-detection.test.ts new file mode 100644 index 000000000..542702f2e --- /dev/null +++ b/src/resources/extensions/gsd/tests/gsdroot-worktree-detection.test.ts @@ -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// 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// 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", + ); + }); +});