mirror of https://github.com/garrytan/gstack.git
Fix: resolveSlug silently returns "unknown" on Windows
`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, stdout is null, the SLUG regex does not match,
and resolveSlug returns the literal "unknown".
Repro (Windows, git-bash, bun):
bun -e 'const {spawnSync}=require("child_process");
const r=spawnSync("C:/Users/<u>/.claude/skills/gstack/bin/gstack-slug",
{encoding:"utf-8"});
console.log(r.error&&r.error.code, JSON.stringify(r.stdout))'
# ENOENT null
The impact is worse than a wrong path. Both consumers — gstack-decision-log
and gstack-decision-search — then read and write ~/.gstack/projects/unknown/.
On the write side that is a single 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. The `?? "unknown"`
fallback turned an exec failure into a plausible value, which is why this
went unnoticed for so long.
`shell: true` does not fix it — cmd.exe has no association for an
extension-less file and yields "unknown" too. Naming the interpreter does.
Two changes:
- Retry via `bash <script>` when the direct call yields no slug. The direct
call is still attempted first, so POSIX behaviour is unchanged. The retry
is keyed on "no slug parsed" rather than on ENOENT specifically, so a shim
that exits non-zero without output is covered as well. bash and not sh:
gstack-slug uses `[[ ]]` and `set -o pipefail`.
- Warn on stderr before falling back. The fallback value is kept for
compatibility, but a tooling failure is not an anonymous project, and a
mute fallback is what allowed this to live.
Verified on Windows 11 / git-bash / bun 1.3.14:
- nominal: resolveSlug returns benjaminberes-bp-alfred-agent
- failure: warns `direct: ENOENT; via bash: exited 127` and still returns
"unknown", so no caller breaks
- end to end: `gstack-decision-search --recent 5` goes from empty to 4
records against an existing 3930-byte decisions.active.json
gitBranch() in the same file is unaffected: it spawns `git`, which Windows
resolves via PATHEXT. Only extension-less scripts break, so an audit should
look for spawnSync calls targeting bin/gstack-*.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a3259400a3
commit
24eb07e477
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue