From 57a211e4b0edaa1b016db83a538613d82088b4bc Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 15:49:19 -0700 Subject: [PATCH] fix(redact-prepush): preserve the trailing newline handed to chained pre-push.local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chaining wrapper captured stdin with $(cat), which strips the trailing newline — a chained shell hook built on `while read` then never entered its loop for the final (usually only) ref line and exited 0, failing OPEN. Use the printf-x sentinel so the byte-exact input reaches the chained hook, with tests covering both the pass-through and the short-circuit paths. Contributed by @francis-eye (PR #2358). Co-Authored-By: Claude Fable 5 --- bin/gstack-redact | 7 ++++- test/redact-prepush-hook.test.ts | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/bin/gstack-redact b/bin/gstack-redact index 41bd54c65..edcea8ef4 100755 --- a/bin/gstack-redact +++ b/bin/gstack-redact @@ -73,10 +73,15 @@ function installPrepushHook(): void { } // stdin is single-consume: capture it once, feed both the chained hook and ours. + // The `printf x` sentinel preserves the trailing newline that `$(cat)` strips. + // Without it, a chained shell pre-push.local built on `while read` silently + // drops the final (often only) ref line and exits 0 — the guard reports + // success having scanned nothing, i.e. it fails OPEN. const wrapper = `#!/usr/bin/env bash ${MANAGED_MARKER} set -euo pipefail -_input="$(cat)" +_input="$(cat; printf x)" +_input="\${_input%x}" _local="$(git rev-parse --git-path hooks/pre-push.local)" if [ -x "$_local" ]; then printf '%s' "$_input" | "$_local" "$@" || exit $? diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index b738380ca..67a39db0f 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -216,6 +216,54 @@ describe("install / chaining", () => { expect(fs.readFileSync(path.join(hookDir, "pre-push.local"), "utf8")).toContain("echo mine"); }); + // Regression: `_input="$(cat)"` strips the trailing newline, so a chained + // shell hook using `while read` never entered its loop body for the final + // (usually only) ref line — it saw zero refs and exited 0, failing OPEN. + test("chained pre-push.local receives the final ref line (trailing newline preserved)", () => { + const hookDir = path.join(repo, ".git", "hooks"); + fs.mkdirSync(hookDir, { recursive: true }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo }); + + const seen = path.join(repo, "seen.txt"); + fs.writeFileSync( + path.join(hookDir, "pre-push.local"), + `#!/usr/bin/env bash\nwhile read -r a b c d; do echo "$a $b $c $d" >> ${JSON.stringify(seen)}; done\nexit 0\n`, + { mode: 0o755 }, + ); + + const sha = "a".repeat(40); + const line = `refs/heads/main ${sha} refs/heads/main ${ZERO}\n`; + const r = spawnSync("bash", [path.join(hookDir, "pre-push")], { + cwd: repo, + input: Buffer.from(line), + encoding: "utf8", + env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip" }, + }); + expect(r.status).toBe(0); + expect(fs.existsSync(seen)).toBe(true); + expect(fs.readFileSync(seen, "utf8").trim()).toBe( + `refs/heads/main ${sha} refs/heads/main ${ZERO}`, + ); + }); + + test("a blocking pre-push.local still short-circuits the push", () => { + const hookDir = path.join(repo, ".git", "hooks"); + fs.mkdirSync(hookDir, { recursive: true }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo }); + fs.writeFileSync( + path.join(hookDir, "pre-push.local"), + "#!/usr/bin/env bash\nwhile read -r _a _b _c _d || [ -n \"${_a:-}\" ]; do exit 1; done\nexit 0\n", + { mode: 0o755 }, + ); + const r = spawnSync("bash", [path.join(hookDir, "pre-push")], { + cwd: repo, + input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`), + encoding: "utf8", + env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip" }, + }); + expect(r.status).toBe(1); + }); + test("uninstall restores the chained original", () => { const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true });