diff --git a/lib/bin-context.ts b/lib/bin-context.ts index 28021b56a..3260951ac 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -6,9 +6,9 @@ */ import { spawnSync } from "child_process"; -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs"; import { homedir } from "os"; -import { basename, join } from "path"; +import { basename, dirname, join } from "path"; /** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */ function sanitizeSlug(s: string): string { @@ -28,42 +28,125 @@ export function toMsysPath(p: string): string { return drive ? `/${drive[1].toLowerCase()}${body}` : body; } +/** `-f` in bash terms: a regular file (following symlinks), never a directory. */ +function isFile(p: string): boolean { + try { + return statSync(p).isFile(); + } catch { + return false; + } +} + +// Marker tiers mirror bin/gstack-slug's `_outermost_project_root` exactly. +// STRONG = canonical version-control / language project files ("this directory +// is a real project of its own"); .git is checked separately because it can be +// a directory (normal repo) or a file (worktree / submodule pointer). +// WEAK = content-only project signals (markdown bundles, asset collections). +const STRONG_FILE_MARKERS = [".project.yaml", "package.json", "pyproject.toml", "Cargo.toml", "Gemfile", "go.mod"]; +const WEAK_FILE_MARKERS = ["README.md", "README", "README.rst", "LICENSE", "LICENSE.md"]; + +/** + * Native port of bin/gstack-slug's `_outermost_project_root` (:77-113): walk UP + * from `startDir` tracking the OUTERMOST ancestor holding a strong marker and + * the outermost holding a weak marker. Outermost STRONG wins; else outermost + * WEAK; else "". Build/deploy artifacts (.vercel, node_modules, dist, ...) are + * deliberately NOT markers, so they can't establish a phantom project root. + * + * Termination mirrors the bash fix for windows-free-tests: break on dirname's + * FIXED POINT (drive roots `C:\`, relative `.`, UNC `//srv` never reach the + * literal "/"), with a 64-depth belt-and-braces cap. Exported for the + * hostile-path termination tests. + */ +export function outermostProjectRoot(startDir: string): string { + let dir = startDir; + let outermostStrong = ""; + let outermostWeak = ""; + let depth = 0; + while (dir && dir !== "/" && depth < 64) { + if (existsSync(join(dir, ".git")) || STRONG_FILE_MARKERS.some((m) => isFile(join(dir, m)))) { + outermostStrong = dir; + } else if (WEAK_FILE_MARKERS.some((m) => isFile(join(dir, m)))) { + outermostWeak = dir; + } + const parent = dirname(dir); + if (parent === dir) break; // dirname fixed point (C:\, ., //srv) + dir = parent; + depth += 1; + } + // Strong markers win over weak; either wins over nothing. + return outermostStrong || outermostWeak; +} + /** * Native port of bin/gstack-slug's resolution order, used when that script cannot be - * spawned (see resolveSlug). Same three steps, same alphabet, same cache file — so - * this and the shell path always agree. They must: the bins WRITE using this, while - * the Context Recovery preamble READS using the script. + * spawned (see resolveSlug). Same steps, same alphabet, same cache file — so this and + * the shell path always agree. They must: the bins WRITE using this, while the + * Context Recovery preamble READS using the script. + * + * Resolution order (parity with the bash script, pinned by + * test/bin-context-windows-slug.test.ts against test/gstack-slug-cwd-walk-up.test.ts): + * 0. $GSTACK_PROJECT_SLUG env override — wins over everything, never cached. + * 1. Walk UP to the OUTERMOST project root (see outermostProjectRoot). Without + * the walk, a nested/vendored repo derived its slug from the INNERMOST + * `git remote get-url origin`, splitting the store the bash side keeps whole. + * 2. Cached slug is sticky — EXCEPT the provable old-bug shape (#1125): cached + * value equals basename(cwd) while the walk-up says cwd is NOT the project + * root; that cache came from the pre-walk-up resolver, so recompute and heal. + * 3. Git remote AT THE PROJECT ROOT: [:/]/[.git] → owner-repo. + * 4. Project root's basename; else basename(cwd) for plain non-project folders. */ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string { const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); const cacheDir = join(home, "slug-cache"); const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")); + // 0. explicit env override — per-invocation escape hatch, never persisted + // (caching it would rebind THIS cwd's slug for every later env-less run). + const envSlug = sanitizeSlug((process.env.GSTACK_PROJECT_SLUG || "").trim()); + if (envSlug) return envSlug; + + // 1. outermost project root along the cwd ancestor chain (may be ""). + const projectRoot = outermostProjectRoot(cwd); + let slug = ""; - // 1. cached slug wins (guarantees consistency across sessions) + // 2. cached slug is sticky (#2212), except the old-bug shape (#1125). if (existsSync(cacheFile)) { try { - slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim()); + const cached = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim()); + const pwdBase = sanitizeSlug(basename(cwd)); + const oldBugShape = cached === pwdBase && projectRoot !== "" && projectRoot !== cwd; + if (cached && !oldBugShape) slug = cached; } catch { slug = ""; } } - // 2. else derive from the git remote: [:/]/[.git] → owner-repo - if (!slug) { - const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd }); + // 3. derive from the project root's git remote (a subdir without its own + // remote inherits the parent's — same as `git -C "$PROJECT_ROOT"`). + if (!slug && projectRoot) { + const r = spawnSync("git", ["-C", projectRoot, "remote", "get-url", "origin"], { encoding: "utf-8" }); const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/); if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-")); } - // 3. else the directory name + // 4. project root's basename, else pwd basename for plain folders. + if (!slug && projectRoot) slug = sanitizeSlug(basename(projectRoot)); if (!slug) slug = sanitizeSlug(basename(cwd)); if (!slug) return "unknown"; - // 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`) + // 5. cache it, as gstack-slug does — atomic, self-healing (only rewrites when + // the value changed — single-shot, key-local), and failures stay silent. try { - mkdirSync(cacheDir, { recursive: true }); - const tmp = `${cacheFile}.tmp.${process.pid}`; - writeFileSync(tmp, slug, "utf-8"); - renameSync(tmp, cacheFile); + let current = ""; + try { + current = readFileSync(cacheFile, "utf-8"); + } catch { + // no cache yet — write below + } + if (current !== slug) { + mkdirSync(cacheDir, { recursive: true }); + const tmp = `${cacheFile}.tmp.${process.pid}`; + writeFileSync(tmp, slug, "utf-8"); + renameSync(tmp, cacheFile); + } } catch { // best-effort cache; a miss only costs a re-derive on the next call } diff --git a/test/bin-context-windows-slug.test.ts b/test/bin-context-windows-slug.test.ts index 47f05b8da..be7291d8d 100644 --- a/test/bin-context-windows-slug.test.ts +++ b/test/bin-context-windows-slug.test.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { toMsysPath, slugFromEnvironment, + outermostProjectRoot, resolveSlug, NEEDS_NATIVE_SLUG_ON_WINDOWS, } from "../lib/bin-context"; @@ -14,8 +15,21 @@ const ROOT = path.resolve(import.meta.dir, ".."); const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8"); let tmp: string; -beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); }); -afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }); +let savedEnvSlug: string | undefined; +beforeEach(() => { + // realpathSync so the native cwd matches what the bash script's `pwd` reports + // (macOS: /var/folders/... is a symlink to /private/var/folders/...). + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-"))); + // An ambient GSTACK_PROJECT_SLUG (leaked from an operator shell or a sibling + // test in a shared-process shard) would override every derivation under test. + savedEnvSlug = process.env.GSTACK_PROJECT_SLUG; + delete process.env.GSTACK_PROJECT_SLUG; +}); +afterEach(() => { + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} + if (savedEnvSlug === undefined) delete process.env.GSTACK_PROJECT_SLUG; + else process.env.GSTACK_PROJECT_SLUG = savedEnvSlug; +}); /** * Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file @@ -111,3 +125,183 @@ describe("the fallback stays win32-gated", () => { } }); }); + +/** + * Walk-up parity: the native fallback must resolve the same OUTERMOST project + * root as bin/gstack-slug's `_outermost_project_root` (see + * test/gstack-slug-cwd-walk-up.test.ts for the bash-side pins). Before this + * port, the native path derived the slug from `git remote get-url origin` in + * cwd — the INNERMOST repo — so a Windows session inside a nested/vendored + * repo or an artifact-only subdir filed its state under a different slug than + * every bash-side consumer. + * + * Each scenario is run through BOTH implementations on the same fixture + * (separate GSTACK_HOMEs so neither reads the other's cache) and pinned to the + * same expected slug. The bash leg is skipped on win32, where bash isn't + * reliably spawnable — the native leg still pins the ported semantics there. + */ +describe("walk-up parity with bin/gstack-slug (outermost project root)", () => { + const SCRIPT = path.join(ROOT, "bin", "gstack-slug"); + const HAS_BASH = process.platform !== "win32"; + + function bashSlug(cwd: string, extraEnv: Record = {}): string { + const env: Record = { + ...process.env, + HOME: path.join(tmp, "bash-home"), + GSTACK_HOME: path.join(tmp, "bash-home", ".gstack"), + }; + delete env.GSTACK_PROJECT_SLUG; // only set when a scenario passes it explicitly + Object.assign(env, extraEnv); + const r = spawnSync("bash", [SCRIPT], { cwd, env, encoding: "utf-8", timeout: 10_000 }); + const m = (r.stdout || "").match(/^SLUG=([^\n]*)$/m); + return m ? m[1] : ""; + } + + const nativeHome = () => path.join(tmp, "native-home"); + + /** Assert native === expected, and bash === expected where bash is available. */ + function expectBoth(cwd: string, expected: string) { + expect(slugFromEnvironment(nativeHome(), cwd)).toBe(expected); + if (HAS_BASH) expect(bashSlug(cwd)).toBe(expected); + } + + test("AC-1: .git at root, artifact-only subdir — slug is the ROOT basename", () => { + const projectRoot = path.join(tmp, "loadout"); + const siteSubdir = path.join(projectRoot, "site"); + fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true }); + fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true }); + fs.writeFileSync(path.join(siteSubdir, ".vercel", "project.json"), "{}\n"); + expectBoth(siteSubdir, "loadout"); + }); + + test("AC-1 variant: package.json at root, node_modules-only subdir — ROOT basename", () => { + const projectRoot = path.join(tmp, "monorepo"); + const subdir = path.join(projectRoot, "packages", "web"); + fs.mkdirSync(subdir, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "package.json"), "{}\n"); + fs.mkdirSync(path.join(subdir, "node_modules"), { recursive: true }); + expectBoth(subdir, "monorepo"); + }); + + test("AC-2: stale cache (old-bug shape) self-heals to the outermost-root slug", () => { + const projectRoot = path.join(tmp, "loadout"); + const siteSubdir = path.join(projectRoot, "site"); + fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true }); + fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true }); + // Pre-seed the native cache with the WRONG value (pre-walk-up poisoning: + // cached == basename(pwd) while pwd is NOT the project root). + const cacheDir = path.join(nativeHome(), "slug-cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + const cacheFile = path.join(cacheDir, toMsysPath(siteSubdir).replace(/\//g, "_")); + fs.writeFileSync(cacheFile, "site"); + + expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("loadout"); + // The cache file itself must have been overwritten (self-healing). + expect(fs.readFileSync(cacheFile, "utf-8")).toBe("loadout"); + }); + + test("sticky cache (#2212): a cached identity that is NOT the old-bug shape survives", () => { + const projectRoot = path.join(tmp, "renamed-project"); + fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true }); + const cacheDir = path.join(nativeHome(), "slug-cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + const cacheFile = path.join(cacheDir, toMsysPath(projectRoot).replace(/\//g, "_")); + fs.writeFileSync(cacheFile, "legacy-name"); + // cached != basename(pwd), so the sticky rule holds — no recompute. + expect(slugFromEnvironment(nativeHome(), projectRoot)).toBe("legacy-name"); + }); + + test("AC-3: cwd IS the project root with .git — slug = basename", () => { + const projectRoot = path.join(tmp, "myproject"); + fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true }); + expectBoth(projectRoot, "myproject"); + }); + + test("AC-4: no markers anywhere on the chain — slug = pwd basename (fallback)", () => { + const deep = path.join(tmp, "just", "a", "plain", "folder"); + fs.mkdirSync(deep, { recursive: true }); + expectBoth(deep, "folder"); + }); + + test("AC-5: subdir of a repo with a remote — slug derived from the ROOT's remote", () => { + // Pins the `git -C "$PROJECT_ROOT"` port: the subdir has no repo of its + // own, so the old native path (git in cwd) also reached the parent repo — + // but only the walk-up guarantees BOTH implementations root the remote + // lookup at the same directory. + const projectRoot = path.join(tmp, "realgit"); + const subdir = path.join(projectRoot, "src", "deep"); + fs.mkdirSync(subdir, { recursive: true }); + spawnSync("git", ["init", "-q", projectRoot]); + spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"]); + expectBoth(subdir, "foo-bar"); + }); + + test("weak marker: README.md at root, artifact-only subdir — ROOT basename", () => { + const projectRoot = path.join(tmp, "loadout"); + const siteSubdir = path.join(projectRoot, "site"); + fs.mkdirSync(siteSubdir, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n"); + fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true }); + expectBoth(siteSubdir, "loadout"); + }); + + test("two-tier: vendored sub-repo with .git wins over parent README (strong > weak)", () => { + const projectRoot = path.join(tmp, "loadout"); + const subRepo = path.join(projectRoot, "starter-pack"); + fs.mkdirSync(subRepo, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n"); + fs.mkdirSync(path.join(subRepo, ".git"), { recursive: true }); + expectBoth(subRepo, "starter-pack"); + }); + + test("two-tier: outermost weak wins when no strong marker exists on the chain", () => { + const projectRoot = path.join(tmp, "loadout"); + const subdir = path.join(projectRoot, "docs"); + fs.mkdirSync(subdir, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n"); + fs.writeFileSync(path.join(subdir, "README.md"), "# docs\n"); + expectBoth(subdir, "loadout"); + }); + + test("nested repo: outermost .git wins — nested/vendored repos don't split stores", () => { + // THE bug this port fixes: the old native path asked the INNERMOST repo's + // remote. bin/gstack-slug resolves the OUTERMOST strong marker instead. + const outer = path.join(tmp, "outer-project"); + const inner = path.join(outer, "vendor", "inner-lib"); + fs.mkdirSync(inner, { recursive: true }); + spawnSync("git", ["init", "-q", outer]); + spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"]); + spawnSync("git", ["init", "-q", inner]); + spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]); + expectBoth(inner, "acme-outer"); + }); + + test("GSTACK_PROJECT_SLUG env override beats every other resolution path, never cached", () => { + const projectRoot = path.join(tmp, "loadout"); + const siteSubdir = path.join(projectRoot, "site"); + fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true }); + fs.mkdirSync(siteSubdir, { recursive: true }); + + process.env.GSTACK_PROJECT_SLUG = "custom-override"; + try { + expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("custom-override"); + if (HAS_BASH) { + expect(bashSlug(siteSubdir, { GSTACK_PROJECT_SLUG: "custom-override" })).toBe("custom-override"); + } + } finally { + delete process.env.GSTACK_PROJECT_SLUG; + } + // Per-invocation escape hatch, never a durable identity: no cache written. + const cacheFile = path.join(nativeHome(), "slug-cache", toMsysPath(siteSubdir).replace(/\//g, "_")); + expect(fs.existsSync(cacheFile)).toBe(false); + }); + + test("outermostProjectRoot terminates on hostile path forms (dirname fixed points)", () => { + // Mirrors the windows-free-tests regression on the bash side: mixed-form + // paths must hit the dirname fixed point, not loop. A hang here would trip + // the suite timeout; reaching the assertions IS the pass. + for (const hostile of ["C:/Users/nobody/project", ".", "//server/share/dir"]) { + expect(typeof outermostProjectRoot(hostile)).toBe("string"); + } + }); +});