mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995) /land-and-deploy gated only on required checks, so a repo with none (every GitHub Free private repo, any repo without branch protection) skipped the CI wait and could merge over red CI. The verdict now comes from bin/gstack-ci-gate, a tested helper bound to the head SHA: all checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit per-head approval, and both merge commands (including --auto) run only after a PASS for the same SHA they pin with --match-head-commit. Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901) gstack-slug kept only the last two remote path segments, so customer-a/product/repo and customer-b/product/repo shared one bucket and /context-restore could present another project's checkpoint. - One canonical-remote rule (bash + TS, golden vectors) used by all six resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and Bitbucket Server normalized. 3+ segment remotes get <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged. - Versioned sticky cache (v2:) keeps cache hits git-free. - context-save stamps the credential-free remote and root; context-restore only treats this project's checkpoints as latest, reports foreign/unverified ones first, and treats a different root with the same remote (Conductor handoff) as information. - gstack-slug --adopt-legacy copies only files the user confirms. - question-preference-hook reads the same versioned cache, so never-ask preferences fire; unknown identity asks instead of auto-deciding. * fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922) - brain-context-load called 'gbrain list_pages' (an MCP tool name), so every skill's brain context was 0 bytes. It now uses 'gbrain list' with gbrain's real filter flags, and prints one line when queries fail. - The code-stage sync always passes --no-pull (managed gbrain >= 0.51 refuses without it); a gbrain that rejects the flag fails with an upgrade message and is never retried without it. stderr is streamed with a bounded tail and quoted in failure summaries. - /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by default it skips types already synced through a federated curated source, so curated pages stop being re-duplicated. Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011) - Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and never a false PASS; P2/P3-only output passes. - import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's ROOTPATH (including git-URL targets) through explicit, captured mappings; declared bases must sit inside the source root or the run's checkout, URIs are percent-decoded, and rejected results are counted with rule, base and path instead of silently dropped. * fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984) - The pre-push redact hook exempts a root VERSION file that is only a version string and the version token in CHANGELOG release headings; the engine allowlist is untouched. - gstack-update-check caches an unreadable remote as CHECK_FAILED with a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports 'latest' after a failed check. - SECURITY.md points reporters at GitHub private vulnerability reporting. - pr-title-sync (pull_request_target, write token) pins actions/checkout to a reviewed SHA. * fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006) The guard required a session journal to start with a user message, but Claude Code starts it with SessionStart hook attachments (and a forked compacted session starts with a compact_boundary), so every phase was denied for anyone with a SessionStart hook. Windows path spellings (C:/, /c/, drive case) were rejected too. - Reader accepts the captured startup, compact, clear and fork shapes (claude 2.1.284 fixture), computes root uniqueness over every candidate, and returns typed reason codes instead of one string. - Hard denials (competing root, foreign cwd, sidechain, agent, cycle, malformed, identity) say what happened and how to run the reviews by hand; unflushed journals still retry. - A well-formed, stable journal whose root shape is unrecognized now warns ('phase publication was NOT verified') and continues, without overriding Claude Code's own permission check; no bypass switch. - Windows paths fold before validation, so '..' is still rejected. - Periodic drift canary runs the reader on fresh journals from the pinned claude. Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com> * v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs * test: seed the versioned slug cache in the memory-injection deny case (#2901) The preference hook now resolves the project bucket from gstack-slug's v2 slug-cache entry instead of basename(cwd); this fixture still wrote preferences under the basename bucket without the entry the skill preamble always writes first. * fix: full-suite follow-ups for the fix wave - gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50 pipe-deadlock guard like the other bins. - Refresh the codex/factory ship goldens for the plan-gates slug change. - The periodic lane gains the autoplan journal drift canary (107 paid files); raise the eval-slices cap to 25 so every slice still starts at once until its recorded duration lets the planner pack it. * fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs The workflow judge scored plan-eng-review's instructions at actionability 3.67 (bar 4) on text unchanged from main. Resolve the ambiguities it named together, without changing any gate or output: Step 0 names the Read's order and keeps one STOP; the pending-call, read-only, forbidden-write and failed-save rules become one Write routes block; header/label limits and grid checks are concrete; Performance gets a checklist; each section has a stated output shape; Lake Score is defined. Same total length (parity cap). --------- Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
477 lines
24 KiB
TypeScript
477 lines
24 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { spawnSync } from "child_process";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
import {
|
|
toMsysPath,
|
|
slugFromEnvironment,
|
|
outermostProjectRoot,
|
|
resolveSlug,
|
|
NEEDS_NATIVE_SLUG_ON_WINDOWS,
|
|
slugCacheFile,
|
|
readVersionedSlugCache,
|
|
} from "../lib/bin-context";
|
|
|
|
const ROOT = path.resolve(import.meta.dir, "..");
|
|
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
|
|
|
let tmp: string;
|
|
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
|
|
* extension -- so spawnSync fails ENOENT and resolveSlug used to return the literal
|
|
* string "unknown". Every decision on the machine landed in one shared
|
|
* ~/.gstack/projects/unknown/ bucket, while the bash-side Context Recovery preamble
|
|
* resolved the real slug and silently found nothing there.
|
|
*
|
|
* These exercise the native fallback on EVERY platform (it is only the *gating* that
|
|
* is win32-specific), so macOS/Linux CI catches a regression that would otherwise
|
|
* only ever surface on a Windows user's disk.
|
|
*/
|
|
describe("native slug fallback mirrors bin/gstack-slug", () => {
|
|
test("toMsysPath reproduces the git-bash cache key", () => {
|
|
// gstack-slug does: CACHE_KEY=$(printf '%s' "$(pwd)" | tr '/' '_')
|
|
// and git-bash `pwd` reports C:\Users\j\foo as /c/Users/j/foo.
|
|
expect(toMsysPath("C:\\Users\\j\\foo")).toBe("/c/Users/j/foo");
|
|
expect(toMsysPath("D:/Work/Repo")).toBe("/d/Work/Repo");
|
|
expect(toMsysPath("/already/posix")).toBe("/already/posix");
|
|
// The cache FILENAME is the real contract:
|
|
expect(toMsysPath("C:\\Users\\j\\foo").replace(/\//g, "_")).toBe("_c_Users_j_foo");
|
|
});
|
|
|
|
test("step 1: a cached slug wins over everything else", () => {
|
|
const cwd = path.join(tmp, "proj");
|
|
fs.mkdirSync(cwd);
|
|
const cacheDir = path.join(tmp, "home", "slug-cache");
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
fs.writeFileSync(path.join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")), "cached-wins");
|
|
expect(slugFromEnvironment(path.join(tmp, "home"), cwd)).toBe("cached-wins");
|
|
});
|
|
|
|
test("step 2: derives owner-repo from the git remote, https and ssh alike", () => {
|
|
for (const [url, want] of [
|
|
["https://github.com/acme/Widget.git", "acme-Widget"],
|
|
["git@github.com:acme/Widget.git", "acme-Widget"],
|
|
["https://gitlab.com/acme/Widget", "acme-Widget"],
|
|
] as const) {
|
|
const cwd = fs.mkdtempSync(path.join(tmp, "repo-"));
|
|
spawnSync("git", ["init", "-q"], { cwd, timeout: 30_000 });
|
|
spawnSync("git", ["remote", "add", "origin", url], { cwd, timeout: 30_000 });
|
|
expect(slugFromEnvironment(path.join(tmp, "home2"), cwd)).toBe(want);
|
|
}
|
|
});
|
|
|
|
test("step 3: falls back to the sanitized directory name", () => {
|
|
// `tr -cd 'a-zA-Z0-9._-'` DELETES disallowed characters rather than replacing them.
|
|
const cwd = path.join(tmp, "My Proj+v2");
|
|
fs.mkdirSync(cwd);
|
|
expect(slugFromEnvironment(path.join(tmp, "home3"), cwd)).toBe("MyProjv2");
|
|
});
|
|
|
|
test("the resolved slug is cached back, as the shell script does", () => {
|
|
const cwd = path.join(tmp, "cacheme");
|
|
fs.mkdirSync(cwd);
|
|
const home = path.join(tmp, "home4");
|
|
const slug = slugFromEnvironment(home, cwd);
|
|
const key = path.join(home, "slug-cache", toMsysPath(cwd).replace(/\//g, "_"));
|
|
expect(fs.existsSync(key)).toBe(true);
|
|
// no trailing newline: gstack-slug writes with printf '%s'
|
|
expect(fs.readFileSync(key, "utf-8")).toBe(`v2:${slug}`);
|
|
});
|
|
|
|
test("never returns the empty string", () => {
|
|
expect(slugFromEnvironment(path.join(tmp, "h"), tmp).length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("the fallback stays win32-gated", () => {
|
|
// Static tripwire in the style of gbrain-spawn-windows-shell.test.ts: POSIX CI
|
|
// cannot observe the Windows branch at runtime, so pin the gate itself. Removing
|
|
// it would silently change macOS/Linux behaviour, which today is byte-identical.
|
|
test("NEEDS_NATIVE_SLUG_ON_WINDOWS is platform-gated", () => {
|
|
expect(read("lib/bin-context.ts")).toMatch(
|
|
/export const NEEDS_NATIVE_SLUG_ON_WINDOWS\s*=\s*process\.platform === "win32"/,
|
|
);
|
|
expect(NEEDS_NATIVE_SLUG_ON_WINDOWS).toBe(process.platform === "win32");
|
|
});
|
|
|
|
test("resolveSlug no longer returns a bare literal on a failed spawn", () => {
|
|
const src = read("lib/bin-context.ts");
|
|
expect(src).not.toMatch(/return m \? m\[1\]\.trim\(\) : "unknown";/);
|
|
expect(src).toMatch(/if \(NEEDS_NATIVE_SLUG_ON_WINDOWS\) return slugFromEnvironment\(\);/);
|
|
});
|
|
|
|
test("a spawn that cannot run resolves to a real slug, not 'unknown'", () => {
|
|
// The exact production failure: the helper path does not exist / cannot exec.
|
|
const got = resolveSlug(path.join(tmp, "definitely-not-a-real-bin"));
|
|
if (NEEDS_NATIVE_SLUG_ON_WINDOWS) {
|
|
expect(got).not.toBe("unknown");
|
|
} else {
|
|
expect(got).toBe("unknown"); // POSIX behaviour deliberately unchanged
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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("v2: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], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"], { timeout: 30_000 });
|
|
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], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"], { timeout: 30_000 });
|
|
spawnSync("git", ["init", "-q", inner], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"], { timeout: 30_000 });
|
|
expectBoth(inner, "acme-outer");
|
|
});
|
|
|
|
test("LIVE BUG SHAPE: a stray empty .git ancestor no longer degrades the slug (remote-first)", () => {
|
|
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
|
|
// (not a valid repo, no origin) above a canonical-remote repo. The
|
|
// pre-remote-first native path resolved PROJECT_ROOT to the stray marker
|
|
// ancestor, found no origin THERE, and degraded to its basename — filing
|
|
// every repo under it into one shared ~/.gstack/projects/<basename>/.
|
|
const strayHome = path.join(tmp, "strayhome");
|
|
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true }); // empty — invalid repo
|
|
const repo = path.join(strayHome, "work", "repo");
|
|
fs.mkdirSync(repo, { recursive: true });
|
|
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"], { timeout: 30_000 });
|
|
expectBoth(repo, "garrytan-gstack");
|
|
expect(slugFromEnvironment(nativeHome(), repo)).not.toBe("strayhome");
|
|
});
|
|
|
|
test("nested repos under a stray marker: a no-remote outer cannot shadow an inner remote", () => {
|
|
// Outermost REMOTE-bearing repo wins — an outer repo whose origin does
|
|
// not resolve is skipped by the remote walk, so the inner remote-bearing
|
|
// repo carries identity (parity with bin/gstack-slug's _outermost_remote_repo).
|
|
const outer = path.join(tmp, "outer-plain");
|
|
const inner = path.join(outer, "vendor", "inner-lib");
|
|
fs.mkdirSync(inner, { recursive: true });
|
|
spawnSync("git", ["init", "-q", outer], { timeout: 30_000 }); // no origin — marker-only repo
|
|
spawnSync("git", ["init", "-q", inner], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"], { timeout: 30_000 });
|
|
expectBoth(inner, "vendor-inner");
|
|
});
|
|
|
|
test("degraded-ancestor cache self-heals: the pre-remote-first cached value is rewritten", () => {
|
|
// Pre-fix, the resolver cached basename(PROJECT_ROOT) for the stray
|
|
// marker ancestor. cached == the marker root's basename while a
|
|
// remote-bearing repo BELOW it exists → recompute + heal the cache.
|
|
const strayHome = path.join(tmp, "strayhome");
|
|
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true });
|
|
const repo = path.join(strayHome, "git", "proj");
|
|
fs.mkdirSync(repo, { recursive: true });
|
|
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"], { timeout: 30_000 });
|
|
|
|
const cacheDir = path.join(nativeHome(), "slug-cache");
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
|
|
fs.writeFileSync(cacheFile, "strayhome"); // the degraded value the old resolver cached
|
|
|
|
expect(slugFromEnvironment(nativeHome(), repo)).toBe("garrytan-gstack");
|
|
// The cache file itself must have been overwritten (self-healing).
|
|
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:garrytan-gstack");
|
|
});
|
|
|
|
test("package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only", () => {
|
|
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
|
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
|
// ancestor heal must NOT fire — it is restricted to marker roots anchored
|
|
// by a .git entry whose origin does NOT resolve (the live-bug shape).
|
|
const wrapper = path.join(tmp, "wrapperproj");
|
|
const inner = path.join(wrapper, "apps", "web");
|
|
fs.mkdirSync(inner, { recursive: true });
|
|
fs.writeFileSync(path.join(wrapper, "package.json"), '{"name":"wrapper"}\n');
|
|
spawnSync("git", ["init", "-q", inner], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"], { timeout: 30_000 });
|
|
|
|
const cacheDir = path.join(nativeHome(), "slug-cache");
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, toMsysPath(inner).replace(/\//g, "_"));
|
|
fs.writeFileSync(cacheFile, "wrapperproj"); // legit sticky identity
|
|
|
|
expect(slugFromEnvironment(nativeHome(), inner)).toBe("wrapperproj"); // NOT healed to acme-web
|
|
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:wrapperproj");
|
|
|
|
// The bash implementation agrees on the same fixture (own home, seeded cache).
|
|
if (HAS_BASH) {
|
|
const bashCacheDir = path.join(tmp, "bash-home", ".gstack", "slug-cache");
|
|
fs.mkdirSync(bashCacheDir, { recursive: true });
|
|
fs.writeFileSync(path.join(bashCacheDir, toMsysPath(inner).replace(/\//g, "_")), "wrapperproj");
|
|
expect(bashSlug(inner)).toBe("wrapperproj");
|
|
}
|
|
});
|
|
|
|
test("sticky identity preserved (#2212): a remote adopted AT the marker root is NOT healed", () => {
|
|
// Legit sticky shape: the repo that adopted the remote IS the marker root
|
|
// (remote root == project root), so the degraded-ancestor heal must not
|
|
// fire even though cached == basename(project root).
|
|
const repo = path.join(tmp, "stickyproj");
|
|
fs.mkdirSync(repo, { recursive: true });
|
|
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/x/y.git"], { timeout: 30_000 });
|
|
|
|
const cacheDir = path.join(nativeHome(), "slug-cache");
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
|
|
fs.writeFileSync(cacheFile, "stickyproj"); // pre-origin basename identity
|
|
|
|
expect(slugFromEnvironment(nativeHome(), repo)).toBe("stickyproj");
|
|
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:stickyproj");
|
|
});
|
|
|
|
test('hostile origin `url = ..` never becomes a dot slug — basename fallback (dot-only guard)', () => {
|
|
// git accepts `..` as a remote URL. Unchecked, the derived slug would be
|
|
// ".." — path traversal one level above ~/.gstack/projects/. Both
|
|
// implementations must reject it and fall through to the basename.
|
|
const repo = path.join(tmp, "dotty");
|
|
fs.mkdirSync(repo, { recursive: true });
|
|
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", repo, "remote", "add", "origin", ".."], { timeout: 30_000 });
|
|
expectBoth(repo, "dotty");
|
|
});
|
|
|
|
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("nested-group remotes (#3003): 2/3/4-segment ssh+https agree with bin/gstack-slug and the golden vectors", () => {
|
|
const vectors: Array<{ url: string; slug: string }> = JSON.parse(
|
|
fs.readFileSync(path.join(import.meta.dir, "fixtures", "remote-identity-vectors.json"), "utf-8"),
|
|
);
|
|
const urls = [
|
|
"https://github.com/garrytan/gstack.git",
|
|
"git@github.com:garrytan/gstack.git",
|
|
"https://gitlab.com/customer-a/product/repo.git",
|
|
"ssh://git@gitlab.com:2222/customer-a/product/repo.git",
|
|
"git@gitlab.example.com:group/sub/team/repo.git",
|
|
"https://gitlab.example.com/group/sub/team/repo",
|
|
];
|
|
for (const url of urls) {
|
|
const want = vectors.find((v) => v.url === url)!.slug;
|
|
const repo = fs.mkdtempSync(path.join(tmp, "nested-"));
|
|
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", repo, "remote", "add", "origin", url], { timeout: 30_000 });
|
|
expectBoth(repo, want);
|
|
}
|
|
});
|
|
|
|
test("unversioned legacy cache of a 3-segment remote is upgraded once and rewritten as v2 (#3003)", () => {
|
|
const repo = path.join(tmp, "nested-upgrade");
|
|
fs.mkdirSync(repo, { recursive: true });
|
|
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
|
|
spawnSync("git", ["-C", repo, "remote", "add", "origin", "git@gitlab.com:customer-a/product/repo.git"], { timeout: 30_000 });
|
|
const cacheFile = slugCacheFile(nativeHome(), repo);
|
|
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
fs.writeFileSync(cacheFile, "product-repo"); // what the pre-#3003 resolver cached
|
|
expect(slugFromEnvironment(nativeHome(), repo)).toBe("product-repo-5eca1fe041984543");
|
|
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:product-repo-5eca1fe041984543");
|
|
expect(readVersionedSlugCache(nativeHome(), repo)).toBe("product-repo-5eca1fe041984543");
|
|
if (HAS_BASH) {
|
|
const bashCache = path.join(tmp, "bash-home", ".gstack", "slug-cache", toMsysPath(repo).replace(/\//g, "_"));
|
|
fs.mkdirSync(path.dirname(bashCache), { recursive: true });
|
|
fs.writeFileSync(bashCache, "product-repo");
|
|
expect(bashSlug(repo)).toBe("product-repo-5eca1fe041984543");
|
|
expect(fs.readFileSync(bashCache, "utf-8")).toBe("v2:product-repo-5eca1fe041984543");
|
|
}
|
|
});
|
|
|
|
test("readVersionedSlugCache accepts only v2 entries with a usable value", () => {
|
|
const cwd = path.join(tmp, "cache-reader");
|
|
fs.mkdirSync(cwd);
|
|
const file = slugCacheFile(nativeHome(), cwd);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
expect(readVersionedSlugCache(nativeHome(), cwd)).toBeUndefined();
|
|
for (const [content, want] of [
|
|
["legacy-unversioned", undefined],
|
|
["v2:", undefined],
|
|
["v2:..", undefined],
|
|
["v2:a/../b", "a..b"],
|
|
["v2:owner-repo", "owner-repo"],
|
|
] as const) {
|
|
fs.writeFileSync(file, content);
|
|
expect(readVersionedSlugCache(nativeHome(), cwd)).toBe(want);
|
|
}
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|