fix(windows): resolve the project slug natively when gstack-slug cannot spawn

bin/gstack-slug is a `#!/usr/bin/env bash` script with no file extension. Windows
honors neither the shebang nor PATHEXT for an explicit path, so spawnSync fails
ENOENT and resolveSlug returned its literal fallback, "unknown".

Every decision on the machine was therefore filed under
~/.gstack/projects/unknown/ -- one bucket shared by every project -- while the
bash-side Context Recovery preamble resolved the real slug, found no
decisions.active.json there, and skipped through a bare `if [ -f ... ]` with no
else.

Nothing failed. Both decision bins (log and search) missed identically, so writes
and searches stayed consistent with each other, and the only component that
resolved correctly was silent by design. Measured on one machine: 62 decisions
accumulated over 10 days and 170 skill runs, surfaced zero times.

shell:true is not the fix here, unlike #1731 -- cmd.exe cannot run a bash script
either. Nor is re-spawning through `bash`: on Windows that frequently resolves to
WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache
directory, trading one split store for another.

Instead, port gstack-slug's own three steps (cache -> git remote -> basename),
keeping its alphabet and its MSYS-form cache key so both paths agree. The
fallback is win32-gated, so POSIX behaviour is byte-identical.

Tests exercise the fallback on every platform (only the gating is win32-specific),
so POSIX CI catches a regression that would otherwise surface only on a Windows
user's disk, plus a static gate pinning the platform check.
This commit is contained in:
H M Ibtihal Utsho
2026-08-16 08:46:18 -07:00
committed by Garry Tan
parent de670f69c8
commit dc8657006f
3 changed files with 208 additions and 2 deletions
+3
View File
@@ -119,6 +119,9 @@ jobs:
# Same diagnosability contract as free-tests.yml: a red lane must
# carry the WHY (the runner's quiet console names files, not causes).
# (#2561 was written against the old hand-listed subset; its two new
# test files are pure-TS and flow into the --windows-only curation
# automatically, so no per-file entry is needed here.)
- name: Upload shard logs on failure
if: failure()
uses: actions/upload-artifact@v4
+92 -2
View File
@@ -6,12 +6,102 @@
*/
import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { homedir } from "os";
import { basename, join } from "path";
/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */
/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */
function sanitizeSlug(s: string): string {
return s.replace(/[^a-zA-Z0-9._-]/g, "");
}
/**
* A Windows path in the MSYS form git-bash's `pwd` reports:
* `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form
* (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very
* entry gstack-slug wrote and silently re-derives instead of staying consistent.
* Exported for the cache-key test; non-Windows paths pass through unchanged.
*/
export function toMsysPath(p: string): string {
const drive = p.match(/^([A-Za-z]):[\\/]/);
const body = (drive ? p.slice(2) : p).replace(/\\/g, "/");
return drive ? `/${drive[1].toLowerCase()}${body}` : body;
}
/**
* 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.
*/
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, "_"));
let slug = "";
// 1. cached slug wins (guarantees consistency across sessions)
if (existsSync(cacheFile)) {
try {
slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim());
} 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 });
const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-"));
}
// 3. else the directory name
if (!slug) slug = sanitizeSlug(basename(cwd));
if (!slug) return "unknown";
// 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`)
try {
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
}
return slug;
}
/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no
* PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */
export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32";
/**
* Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`).
*
* On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout`
* is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts
* (#1731). Returning the literal "unknown" filed every decision under
* ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine —
* while the bash-side Context Recovery preamble resolved the real slug, found no
* decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else.
*
* Nothing failed, for ten days: BOTH decision bins (log and search) missed identically,
* so writes and searches stayed consistent with each other, and the only component that
* resolved correctly was silent by design.
*
* `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script
* either. Nor is re-spawning through `bash` — on Windows that frequently resolves to
* WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache
* directory, trading one split store for another.
*
* POSIX behaviour is unchanged: the fallback is win32-only, where the previous result
* was unconditionally wrong and so has nothing to regress.
*/
export function resolveSlug(slugBinPath: string): string {
const r = spawnSync(slugBinPath, { encoding: "utf-8" });
const m = (r.stdout || "").match(/^SLUG=(.+)$/m);
return m ? m[1].trim() : "unknown";
if (m) return m[1].trim();
if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment();
return "unknown";
}
/** Current git branch, or undefined on detached HEAD / outside a repo. */
+113
View File
@@ -0,0 +1,113 @@
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,
resolveSlug,
NEEDS_NATIVE_SLUG_ON_WINDOWS,
} 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;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); });
afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
/**
* 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 });
spawnSync("git", ["remote", "add", "origin", url], { cwd });
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(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
}
});
});