mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
fix(bin-context): native slug fallback walks up like bash gstack-slug
slugFromEnvironment derived the slug from the INNERMOST repo's origin while bash gstack-slug walks to the outermost project root — nested/vendored repos split their stores across the bash/native boundary (win32 hits the native path constantly). The native fallback now ports _outermost_project_root faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap, fixed-point termination) plus the full resolution order: env override → walk-up → sticky cache with the #1125 self-heal → remote get-url → basename. Twelve mirrored scenarios drive BOTH implementations against the same fixtures and pin identical slugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6df30370b3
commit
7f749f94fe
+99
-16
@@ -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: [:/]<owner>/<repo>[.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: [:/]<owner>/<repo>[.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
|
||||
}
|
||||
|
||||
@@ -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, string> = {}): string {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user