fix(bin): route remaining Windows-reachable mkdirSync sites through mkdirpSync

Sweep follow-up to #2641's lib/fs-utils.ts helper: bun on Windows throws EEXIST from a recursive mkdir on an existing dir, so every unguarded recursive mkdirSync on a Windows-reachable path is a latent crash. Converted: bin/gstack-decision-log (unguarded, runs on every decision log — the second call on any machine hits the pre-existing projects dir), bin/gstack-evidence logsDir + ledger dir sites, and bin/gstack-redact-prepush's skip-log site (already try-wrapped, so its failure mode was a silent skip-log loss rather than a crash — the fix makes the log survive). The ~15 remaining gbrain/mac-lane sites are deliberately left alone.

Regression: fs-utils.test.ts drives gstack-decision-log twice, the second run under the bun-Windows EEXIST preload fixture — the pre-sweep code exits 1 with EEXIST there; verified red against v1.68.3.0.
This commit is contained in:
Garry Tan
2026-08-22 01:59:16 +00:00
parent 790c42505a
commit 361dfebcbe
4 changed files with 40 additions and 6 deletions
+4 -2
View File
@@ -14,8 +14,8 @@
* validateDecide; a rejected decision exits 1 with a message, nothing persisted.
*/
import { mkdirSync } from "fs";
import { dirname } from "path";
import { mkdirpSync } from "../lib/fs-utils";
import { spawnSync } from "child_process";
import {
decisionPaths,
@@ -33,7 +33,9 @@ const HERE = import.meta.dir;
const args = process.argv.slice(2);
const slug = resolveSlug(`${HERE}/gstack-slug`);
const paths = decisionPaths(slug);
mkdirSync(dirname(paths.log), { recursive: true });
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
// recursive mkdir on an existing dir (#2635), and this runs on every log call.
mkdirpSync(dirname(paths.log));
function enqueue(): void {
// Fire-and-forget cross-machine sync (no-op when artifacts_sync is off).
+4 -3
View File
@@ -34,7 +34,8 @@
* in the ledger; it cannot prove that an expected lane ever ran.
*/
import { mkdirSync, openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync, readFileSync } from "fs";
import { openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync, readFileSync } from "fs";
import { mkdirpSync } from "../lib/fs-utils";
import { join, dirname } from "path";
import { spawnSync } from "child_process";
import { appendJsonl, readJsonl } from "../lib/jsonl-store";
@@ -137,7 +138,7 @@ function pruneOldLogs(logsDir: string): void {
/** Exclusive-open a collision-safe log file. Returns undefined on failure. */
function openLog(logsDir: string, label: string, cmdSha: string): { fd: number; path: string } | undefined {
try {
mkdirSync(logsDir, { recursive: true });
mkdirpSync(logsDir);
pruneOldLogs(logsDir);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const base = `${ts}-${label}-${process.pid}-${cmdSha.slice(0, 8)}`;
@@ -266,7 +267,7 @@ async function cmdRun(argv: string[]): Promise<number> {
let paths: ReturnType<typeof ledgerPath> | undefined;
try {
paths = ledgerPath();
mkdirSync(paths.dir, { recursive: true });
mkdirpSync(paths.dir);
} catch (e: any) {
warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`);
}
+5 -1
View File
@@ -30,6 +30,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { scan, type Finding } from "../lib/redact-engine";
import { mkdirpSync } from "../lib/fs-utils";
const ZERO = /^0+$/;
// The canonical empty-tree object; diffing against it yields all content as added.
@@ -349,7 +350,10 @@ function logSkip(reason: string): void {
try {
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
const dir = path.join(home, "security");
fs.mkdirSync(dir, { recursive: true });
// mkdirpSync, not bare mkdirSync: bun-on-Windows EEXIST (#2635). This site
// is try-wrapped by the caller, so the old failure was a silent skip-log
// loss rather than a crash — the fix makes the log survive, not un-crash.
mkdirpSync(dir);
fs.appendFileSync(
path.join(dir, "prepush-skip.jsonl"),
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
+27
View File
@@ -61,6 +61,33 @@ describe("mkdirpSync", () => {
});
});
describe("swept mkdirp sites under bun-on-Windows EEXIST semantics (#2635)", () => {
const DECISION_LOG = path.resolve(import.meta.dir, "..", "bin", "gstack-decision-log");
test("decision-log still writes when its projects dir already exists", () => {
// Proves the sweep WIRING, not just the helper: the first call creates
// ~/.gstack/projects/<slug>/, the second hits the emulated Windows EEXIST
// on that pre-existing dir — bare mkdirSync crashed here before the sweep.
const base = tmpdir();
try {
const work = path.join(base, "work");
fs.mkdirSync(work, { recursive: true });
const payload = '{"decision":"eexist probe","rationale":"r","scope":"repo","source":"user"}';
const env = { ...process.env, HOME: base };
const first = spawnSync("bun", [DECISION_LOG, payload], { cwd: work, encoding: "utf8", env });
expect(first.status).toBe(0);
const second = spawnSync(
"bun", ["--preload", EEXIST_PRELOAD, DECISION_LOG, payload],
{ cwd: work, encoding: "utf8", env },
);
expect(second.status).toBe(0);
expect(second.stderr ?? "").not.toContain("EEXIST");
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", () => {
test("install succeeds when .git/hooks already exists, existing hook preserved", () => {
const base = tmpdir();