diff --git a/lib/bin-context.ts b/lib/bin-context.ts index faa1c65a2f..3e4dc880de 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -1,5 +1,5 @@ /** - * bin-context — tiny shared helpers for non-interactive gstack bins that need the + * bin-context -- tiny shared helpers for non-interactive gstack bins that need the * project slug, current branch, and argv flags. Extracted from the decision bins * (gstack-decision-log / gstack-decision-search) so the slug/branch/flag plumbing * lives in one audited place instead of being copy-pasted per bin. @@ -9,9 +9,44 @@ import { spawnSync } from "child_process"; /** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */ 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"; + const parse = (out: string | null): string | undefined => + (out || "").match(/^SLUG=(.+)$/m)?.[1].trim(); + + // Direct call first, so POSIX behaviour is unchanged. + const direct = spawnSync(slugBinPath, { encoding: "utf-8" }); + const fromDirect = parse(direct.stdout); + if (fromDirect) return fromDirect; + + // `gstack-slug` is an extension-less bash script. On Windows, spawnSync + // without a shell goes through CreateProcess, which does not honour + // shebangs: the call fails with ENOENT and stdout is null. `shell: true` + // does not help either -- cmd.exe has no association for an extension-less + // file. Naming the interpreter is what works. Retry whenever the direct + // call yielded no slug rather than only on ENOENT, so a shim that exits + // non-zero without output is covered too. bash specifically, not sh: + // gstack-slug uses `[[ ]]` and `set -o pipefail`. + const viaBash = spawnSync("bash", [slugBinPath], { encoding: "utf-8" }); + const fromBash = parse(viaBash.stdout); + if (fromBash) return fromBash; + + // A tooling failure is not an anonymous project, and a mute fallback is what + // let this live: callers then read and write ~/.gstack/projects/unknown/ -- + // one shared bucket for every repo on the machine on the write side, and on + // the read side a directory that does not exist, so the search returns an + // empty list and exits 0. The session is told there are no prior decisions + // and re-litigates settled calls in good faith. Keep the fallback; never + // keep it quiet. + const reason = (r: typeof direct): string => + (r.error as NodeJS.ErrnoException | undefined)?.code ?? + r.error?.message ?? + `exited ${r.status}`; + process.stderr.write( + `gstack: could not resolve the project slug from ${slugBinPath} ` + + `(direct: ${reason(direct)}; via bash: ${reason(viaBash)}). ` + + `Falling back to "unknown" -- decisions will read and write ` + + `~/.gstack/projects/unknown/ instead of this project.\n`, + ); + return "unknown"; } /** Current git branch, or undefined on detached HEAD / outside a repo. */ diff --git a/test/bin-context-resolve-slug.test.ts b/test/bin-context-resolve-slug.test.ts new file mode 100644 index 0000000000..52fd3f2594 --- /dev/null +++ b/test/bin-context-resolve-slug.test.ts @@ -0,0 +1,60 @@ +/** + * resolveSlug portability guard (free). + * + * `bin/gstack-slug` is an extension-less bash script. On Windows, spawnSync + * without a shell goes through CreateProcess, which does not honour shebangs: + * the direct call fails with ENOENT, stdout is null, and resolveSlug used to + * return the literal "unknown". Both consumers -- gstack-decision-log and + * gstack-decision-search -- then read and write ~/.gstack/projects/unknown/. + * On the write side that is one anonymous bucket shared by every repo on the + * machine; on the read side the directory does not exist, so the search + * returns an empty list and exits 0. The session is told there are no prior + * decisions and re-litigates settled calls in good faith. + * + * Two things are pinned here, because the bug had two halves: + * + * 1. the fallback -- an extension-less shebang script must resolve on every + * platform, which is what the `bash