fix(git-gate): scope new-branch scan to incoming commits
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 40s

A new ref made the pre-receive hook scan the full ancestry
(`log_opts="$new"`), so historical test-fixture findings rejected every
new-branch push (#106). Scope it to `$new --not --all` — only commits
new to the gate, which (since the bare repo is populated solely by
upstream mirror-fetch and gitleaks-gated pushes) loses no coverage on
what a push actually brings to the upstream. Also add BatchMode=yes +
ConnectTimeout=10 to both the forward and access-hook ssh so an
unreachable upstream fails fast instead of hanging.

Refs #106

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 01:59:20 -04:00
parent 9dc0dfd5ee
commit 6c673bece6
2 changed files with 35 additions and 3 deletions
+10 -3
View File
@@ -280,7 +280,14 @@ while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue
if [ "$old" = "$zero" ]; then
log_opts="$new"
# New ref: scan only the commits this push introduces — those
# reachable from $new but not from any ref the gate already has.
# Everything already on the gate arrived via upstream mirror-fetch
# or a previously gitleaks-scanned push, so it's already-upstream
# or already-scanned; re-scanning it (the old `$new` full-ancestry
# range) only resurfaces historical findings and blocks every new
# branch. See PRD 0028 / issue #106.
log_opts="$new --not --all"
else
log_opts="$old..$new"
fi
@@ -300,7 +307,7 @@ if [ ! -f "$hostsfile" ]; then
echo "git-gate: add KnownHostKey to the bottle.git entry and restart the bottle" >&2
exit 1
fi
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10"
while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue
@@ -355,7 +362,7 @@ if [ -z "$keyfile" ] || [ ! -f "$hostsfile" ]; then
echo "git-gate: missing credentials for $repo_dir; refusing fetch" >&2
exit 1
fi
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10"
echo "git-gate: refreshing $repo_dir from upstream" >&2
if ! GIT_SSH_COMMAND="$ssh_cmd" git -C "$repo_dir" fetch origin --prune >&2; then
+25
View File
@@ -197,6 +197,24 @@ class TestHookRender(unittest.TestCase):
# Stdin is buffered to a tempfile so both phases can re-read.
self.assertIn("refs_file=$(mktemp)", hook)
def test_new_ref_scan_scoped_to_incoming_commits(self):
# A new branch (old=all-zeros) must scan only commits new to the
# gate, not the full ancestry — otherwise historical findings
# block every new-branch push (PRD 0028 / issue #106).
hook = git_gate_render_hook()
self.assertIn('log_opts="$new --not --all"', hook)
# The old over-broad full-ancestry range must be gone.
self.assertNotIn('log_opts="$new"', hook)
# Existing-branch delta scan is unchanged.
self.assertIn('log_opts="$old..$new"', hook)
def test_forward_ssh_is_non_interactive_and_bounded(self):
# No prompt (BatchMode) and a connect timeout, so an unreachable
# upstream fails fast instead of hanging the receive-pack.
hook = git_gate_render_hook()
self.assertIn("BatchMode=yes", hook)
self.assertIn("ConnectTimeout=", hook)
class TestAccessHookRender(unittest.TestCase):
def test_access_hook_refreshes_origin_on_upload_pack(self):
@@ -216,6 +234,13 @@ class TestAccessHookRender(unittest.TestCase):
self.assertIn("refusing to serve stale data", hook)
self.assertIn("exit 1", hook)
def test_access_hook_ssh_is_non_interactive_and_bounded(self):
# Same hardening as the forward path: the fetch ssh must not
# prompt and must time out rather than hang upload-pack.
hook = git_gate_render_access_hook()
self.assertIn("BatchMode=yes", hook)
self.assertIn("ConnectTimeout=", hook)
class TestPrepare(unittest.TestCase):
def setUp(self):