diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 1384341..0c3b09f 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -204,6 +204,7 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", " git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"", " git -C \"$repo\" config receive.denyCurrentBranch ignore", + " git -C \"$repo\" config receive.advertisePushOptions true", " git -C \"$repo\" config http.receivepack true", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", "}", @@ -280,6 +281,21 @@ if [ ! -f "$hostsfile" ]; then fi ssh_cmd="ssh -i $keyfile -o UserKnownHostsFile=$hostsfile -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10" +push_option_count=${GIT_PUSH_OPTION_COUNT:-0} +case "$push_option_count" in + ''|*[!0-9]*) + echo "git-gate: invalid GIT_PUSH_OPTION_COUNT=$push_option_count" >&2 + exit 1 + ;; +esac +set -- +i=0 +while [ "$i" -lt "$push_option_count" ]; do + opt=$(printenv "GIT_PUSH_OPTION_$i" || :) + set -- "$@" --push-option="$opt" + i=$((i + 1)) +done + while IFS=' ' read -r old new ref; do [ -z "$ref" ] && continue if [ "$new" = "$zero" ]; then @@ -288,7 +304,7 @@ while IFS=' ' read -r old new ref; do refspec="$new:$ref" fi echo "git-gate: forwarding $ref to origin" >&2 - if ! GIT_SSH_COMMAND="$ssh_cmd" git push origin "$refspec" 1>&2; then + if ! GIT_SSH_COMMAND="$ssh_cmd" git push "$@" origin "$refspec" 1>&2; then echo "git-gate: upstream push failed for $ref" >&2 exit 1 fi diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 4f4a7ea..11e96ea 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -98,6 +98,9 @@ class TestEntrypointRender(unittest.TestCase): # Smart HTTP receive-pack uses the same bare repos and hooks # as git-daemon, so repos must opt in to HTTP pushes too. self.assertIn("http.receivepack true", script) + # The gate must advertise push-option support so clients can + # pass forge-specific options through to the pre-receive hook. + self.assertIn("receive.advertisePushOptions true", script) # The access-hook is what makes fetch a mirror operation # against the upstream (PRD 0008 v1.1). self.assertIn("--access-hook=/etc/git-gate/access-hook", script) @@ -153,7 +156,8 @@ class TestHookRender(unittest.TestCase): hook = git_gate_render_hook() # Phase 1: gitleaks. Phase 2: forward to origin. self.assertIn("gitleaks git", hook) - self.assertIn("git push origin", hook) + self.assertIn("git push", hook) + self.assertIn("origin \"$refspec\"", hook) # KnownHostKey absence is fail-closed. self.assertIn("refusing to push", hook) # Stdin is buffered to a tempfile so both phases can re-read. @@ -177,6 +181,17 @@ class TestHookRender(unittest.TestCase): self.assertIn("BatchMode=yes", hook) self.assertIn("ConnectTimeout=", hook) + def test_forward_preserves_push_options(self): + # Git exposes push options to pre-receive hooks as + # GIT_PUSH_OPTION_COUNT + indexed GIT_PUSH_OPTION_N variables. + # Forward them as first-class argv entries so spaces and shell + # metacharacters inside option values remain data. + hook = git_gate_render_hook() + self.assertIn("push_option_count=${GIT_PUSH_OPTION_COUNT:-0}", hook) + self.assertIn('opt=$(printenv "GIT_PUSH_OPTION_$i" || :)', hook) + self.assertIn('set -- "$@" --push-option="$opt"', hook) + self.assertIn('git push "$@" origin "$refspec"', hook) + class TestAccessHookRender(unittest.TestCase): def test_access_hook_refreshes_origin_on_upload_pack(self):