fix(redact-prepush): resolve the real push base instead of EMPTY_TREE whole-repo scans

When the remote default branch is not main/master (or origin/HEAD is unset),
the merge-base guess failed and the hook fell back to scanning the ENTIRE
repository as added lines — re-attributing long-pushed secrets to the
current push and, on any real repo, tripping the engine byte cap so the push
blocked having scanned nothing. Derive the base from commits reachable from
no remote-tracking branch, keep the empty-tree path only for genuinely fresh
repos, and split the block message so an unscannable diff is reported as
"could not scan (fail closed)" rather than "credential found — rotate it".

Contributed by @stormeoio (PR #2398).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 20:20:49 -07:00
co-authored by Claude Fable 5
parent 69cc39c625
commit d77a2c8e58
2 changed files with 137 additions and 19 deletions
+47
View File
@@ -229,3 +229,50 @@ describe("install / chaining", () => {
expect(restored).not.toContain("managed");
});
});
describe("base resolution when the default branch is neither main nor master", () => {
test("a new branch scans its own commits, not the whole repository", () => {
// The remote's default branch is `trunk` and origin/HEAD is unset, so
// defaultRemoteBranch() falls through to `origin/main` — a ref that does
// not exist — and merge-base fails. The EMPTY_TREE fallback then treats the
// WHOLE repository as added lines, re-scanning history that is already on
// the remote. Two consequences, both bad: a secret long since pushed gets
// re-reported as if this push introduced it, and on any real repository the
// input blows past the engine's byte cap, so `engine.input_too_large`
// blocks the push having scanned NOTHING — the "scans more, never less"
// fallback inverting into "scans nothing".
const bare = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-remote-"));
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare]);
git(["branch", "-M", "trunk"]);
const old = commit("legacy.txt", "AKIA1234567890ABCDEF\n", "secret already on the remote");
git(["remote", "add", "origin", bare]);
git(["push", "-q", "origin", "trunk"]);
// The remote HAS the old commit, and the default-branch guess is unresolvable.
expect(git(["rev-parse", "origin/trunk"])).toBe(old);
expect(git(["rev-parse", "--verify", "origin/main"])).toBe("");
expect(git(["symbolic-ref", "refs/remotes/origin/HEAD"])).toBe("");
git(["checkout", "-q", "-b", "feat"]);
const head = commit("feature.txt", "totally clean\n", "clean feature commit");
const { code, stderr } = runHook(`refs/heads/feat ${head} refs/heads/feat ${ZERO}\n`);
fs.rmSync(bare, { recursive: true, force: true });
// The only NEW content is a clean file. The already-pushed secret must not
// be attributed to this push.
expect(stderr).not.toContain("aws.access_key");
expect(code).toBe(0);
});
test("a genuinely new repository with no remote refs still scans everything", () => {
// Nothing is on any remote, so every commit IS new content: scanning the
// full history is correct here. The narrowing must not open a hole in the
// case the EMPTY_TREE fallback exists for.
const head = commit("secrets.txt", "AKIA1234567890ABCDEF\n", "secret in a fresh repo");
const { code, stderr } = runHook(`refs/heads/feat ${head} refs/heads/feat ${ZERO}\n`);
expect(code).toBe(1);
expect(stderr).toContain("aws.access_key");
});
});