Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions lib/bin-context.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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. */
Expand Down
60 changes: 60 additions & 0 deletions test/bin-context-resolve-slug.test.ts
Original file line number Diff line number Diff line change
@@ -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 <script>` retry buys;
* 2. the diagnosis -- a resolution failure must be audible. A mute fallback
* is what let this live long enough to corrupt sessions' reasoning.
*/

import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveSlug } from '../lib/bin-context';

/** A stand-in for bin/gstack-slug: shebang script, deliberately no extension. */
function fakeSlugBin(body: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-'));
const bin = path.join(dir, 'gstack-slug-fake');
fs.writeFileSync(bin, body);
fs.chmodSync(bin, 0o755);
return bin;
}

describe('resolveSlug', () => {
test('resolves an extension-less shebang script on every platform', () => {
const bin = fakeSlugBin('#!/usr/bin/env bash\necho "SLUG=demo-project"\necho "BRANCH=main"\n');
expect(resolveSlug(bin)).toBe('demo-project');
});

test('warns on stderr rather than returning "unknown" in silence', () => {
const missing = path.join(os.tmpdir(), 'gstack-slug-that-does-not-exist-9f3a');
const seen: string[] = [];
const original = process.stderr.write.bind(process.stderr);
(process.stderr as unknown as { write: unknown }).write = (chunk: unknown) => {
seen.push(String(chunk));
return true;
};
try {
// The fallback value is kept, so no caller breaks...
expect(resolveSlug(missing)).toBe('unknown');
} finally {
(process.stderr as unknown as { write: unknown }).write = original;
}
// ...but it must never be silent.
expect(seen.join('')).toContain('could not resolve the project slug');
});
});