From 35a8d597cdb7ad7316a69a9509fc383291e93983 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 22 Oct 2026 07:30:55 -0700 Subject: [PATCH 1/2] fix: stop the pre-push tracker-leak check hanging on stdin `check-tracker-leaks.ts` read git's ref lines with `Bun.stdin.stream()`, which could wait forever after git closed the pipe, so `git push` never finished. It now reads fd 0 with `readFileSync(0)`. Adds a `tracker-leak-check.test.ts` case that pushes through a real pre-push hook to a bare repo. Co-Authored-By: Claude Opus 5.5 --- .../test/skill/tracker-leak-check.test.ts | 31 +++++++++++++++++++ script/check-tracker-leaks.ts | 7 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/skill/tracker-leak-check.test.ts b/packages/opencode/test/skill/tracker-leak-check.test.ts index 62114ed85a..746f1bddf5 100644 --- a/packages/opencode/test/skill/tracker-leak-check.test.ts +++ b/packages/opencode/test/skill/tracker-leak-check.test.ts @@ -214,4 +214,35 @@ describe("scanner end-to-end (git behaviour)", () => { const r = run(dir, `(delete) ${ZERO} refs/heads/gone ${"1".repeat(40)}\n`) expect(r.status).toBe(0) }) + + test("a real `git push` reads the refs from the hook's stdin and finishes", () => { + // The hook used to wait forever on stdin under `git push`. Run it the way + // git does: a pre-push hook fed by git itself, pushing to a local bare repo. + const dir = repo() + baseline(dir) + const remote = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-remote-")) + created.push(remote) + spawnSync("git", ["init", "-q", "--bare", remote]) + // Outside the work tree: `commit()` stages everything, and a tracked hook + // would vanish on the next branch switch. + const hooks = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-hooks-")) + created.push(hooks) + fs.writeFileSync(path.join(hooks, "pre-push"), `#!/bin/sh\nexec bun ${JSON.stringify(SCRIPT)}\n`, { mode: 0o755 }) + git(dir, "config", "core.hooksPath", hooks) + const push = (ref: string) => + spawnSync("git", ["push", "-q", remote, ref], { cwd: dir, encoding: "utf-8", timeout: 30_000 }) + + const clean = push("main:refs/heads/clean") + expect(clean.signal).toBeNull() + expect(clean.status).toBe(0) + + // HEAD stays clean, so only a scan of the pushed ref from stdin catches this. + git(dir, "checkout", "-qb", "dirty") + commit(dir, `hidden ${key(5151)}`, "leaky work") + git(dir, "checkout", "-q", "main") + const leaky = push("dirty:refs/heads/other") + expect(leaky.signal).toBeNull() + expect(leaky.status).not.toBe(0) + expect(leaky.stderr).toContain(key(5151)) + }) }) diff --git a/script/check-tracker-leaks.ts b/script/check-tracker-leaks.ts index b30aed7ee1..97063a5cbe 100755 --- a/script/check-tracker-leaks.ts +++ b/script/check-tracker-leaks.ts @@ -21,6 +21,7 @@ */ import { $ } from "bun" +import { readFileSync } from "fs" // altimate_change — #1052 D8 review-fix: drop the trailing word-boundary from // the Jira-key regex so the guard catches suffix-adjacent leaks (letter, @@ -185,7 +186,11 @@ async function readPushedRefs(): Promise<{ }> { if (process.stdin.isTTY) return { hadInput: false, tips: [] } try { - const raw = await new Response(Bun.stdin.stream()).text() + // A blocking read(2) of fd 0. `Bun.stdin.stream()` waits on the event loop + // and has hung forever under `git push` with the pipe's writer already closed + // (no peer on fd 0), so the push never finished. A synchronous read returns + // at end of input as soon as no writer remains. + const raw = readFileSync(0, "utf-8") const rows = raw .split("\n") .map((l) => l.trim()) From 20a6457257f468796ada14b57ebfb93fa836607f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 22 Oct 2026 07:37:53 -0700 Subject: [PATCH 2/2] fix: run the tracker-leak check's git calls synchronously too The stuck process had no child, so it may have been awaiting a `Bun.$` git call rather than stdin. `git()` now uses `Bun.spawnSync` with no stdin, so the script never waits on the event loop. Output on a 3,174-file diff matches the old script. Co-Authored-By: Claude Opus 5.5 --- .../test/skill/tracker-leak-check.test.ts | 5 ++-- script/check-tracker-leaks.ts | 28 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/skill/tracker-leak-check.test.ts b/packages/opencode/test/skill/tracker-leak-check.test.ts index 746f1bddf5..205550753c 100644 --- a/packages/opencode/test/skill/tracker-leak-check.test.ts +++ b/packages/opencode/test/skill/tracker-leak-check.test.ts @@ -216,8 +216,9 @@ describe("scanner end-to-end (git behaviour)", () => { }) test("a real `git push` reads the refs from the hook's stdin and finishes", () => { - // The hook used to wait forever on stdin under `git push`. Run it the way - // git does: a pre-push hook fed by git itself, pushing to a local bare repo. + // Runs the script the way git does: a pre-push hook fed by git itself, + // pushing to a local bare repo. This guards the stdin contract end to end; it + // does not reproduce the intermittent ssh hang, which never happened on demand. const dir = repo() baseline(dir) const remote = fs.mkdtempSync(path.join(os.tmpdir(), "tracker-remote-")) diff --git a/script/check-tracker-leaks.ts b/script/check-tracker-leaks.ts index 97063a5cbe..27fb073caf 100755 --- a/script/check-tracker-leaks.ts +++ b/script/check-tracker-leaks.ts @@ -20,7 +20,6 @@ * job of a pre-push guard. */ -import { $ } from "bun" import { readFileSync } from "fs" // altimate_change — #1052 D8 review-fix: drop the trailing word-boundary from @@ -80,15 +79,15 @@ type Hit = { // (b) `catch { return "" }` swallowed real errors (missing ref, corrupted // repo). A failed git command would look identical to a clean scan. // -// `git` is invoked directly via Bun.$ (no shell). Args are passed through the -// tagged-template interpolation which quotes each interpolation as a single -// argv element — no shell parsing anywhere. `.nothrow()` lets us inspect the -// exit code instead of catching an exception. `exitOnFailure` distinguishes -// "expected empty result" (mergeBase against a diverged history) from -// "unexpected failure" (git binary missing, corrupt index) so the latter -// fails loud rather than reporting the branch as clean. +// `git` runs directly from an argv array (no shell), so a caller-supplied value +// is never re-parsed. The exit code is inspected rather than thrown. +// `failOnError` distinguishes "expected empty result" (mergeBase against a +// diverged history) from "unexpected failure" (git binary missing, corrupt +// index) so the latter fails loud rather than reporting the branch as clean. +// The call is synchronous and git gets no stdin: see readPushedRefs for why +// this script avoids waiting on the event loop. async function git(args: string[], opts: { failOnError?: boolean } = { failOnError: true }): Promise { - const r = await $`git ${args}`.quiet().nothrow() + const r = Bun.spawnSync({ cmd: ["git", ...args], stdin: "ignore", stdout: "pipe", stderr: "pipe" }) if (r.exitCode !== 0) { if (opts.failOnError) { process.stderr.write( @@ -98,7 +97,7 @@ async function git(args: string[], opts: { failOnError?: boolean } = { failOnErr } return "" } - return r.text().trim() + return r.stdout.toString().trim() } function scanText(text: string, source: string, hits: Hit[]) { @@ -186,10 +185,11 @@ async function readPushedRefs(): Promise<{ }> { if (process.stdin.isTTY) return { hadInput: false, tips: [] } try { - // A blocking read(2) of fd 0. `Bun.stdin.stream()` waits on the event loop - // and has hung forever under `git push` with the pipe's writer already closed - // (no peer on fd 0), so the push never finished. A synchronous read returns - // at end of input as soon as no writer remains. + // Blocking read of fd 0. Under `git push` over ssh (macOS) this script once + // sat for days with git waiting on the hook: bun idle in its event loop, no + // child process, and the stdin pipe's writer already closed. It was awaiting + // either the stdin stream or a `Bun.$` git call; the event loop missed the + // completion. Neither await is used now: stdin and git are synchronous. const raw = readFileSync(0, "utf-8") const rows = raw .split("\n")