feat(git-gate): provision ~/.gitconfig pushInsteadOf in the bottle
test / unit (pull_request) Successful in 16s
test / integration (pull_request) Successful in 14s

provision_git now does two things: copy the host cwd's .git (when
--cwd is set, existing behavior) and write ~/.gitconfig with
pushInsteadOf rules for each bottle.git entry. A 'git push <real
upstream URL>' from inside the agent transparently rewrites to
'git://<gate>/<name>.git' so the gate gets first crack at the
incoming refs.

pushInsteadOf (not insteadOf) keeps fetch on the original URL —
v1 of the git-gate is push-only scope per PRD 0008. The render
helper is exposed for testing without docker.
This commit is contained in:
2026-05-12 21:01:00 -04:00
parent 2d955a5512
commit 509b1b61e2
2 changed files with 113 additions and 4 deletions
+68 -4
View File
@@ -1,19 +1,38 @@
"""Copy the host cwd's .git directory into a running Docker bottle.
"""Git provisioning inside a running Docker bottle.
Only fires when `--cwd` was passed AND the host cwd actually has a
.git. The container-side path is fixed at /home/node/workspace/.git;
ownership is reset to node so the agent can run git commands."""
Two concerns, both about git in the agent:
1. If --cwd was passed AND the host cwd has a .git, copy that .git
into /home/node/workspace/.git so the agent operates on the
user's repo.
2. If the bottle declares `git` entries (PRD 0008), write a
~/.gitconfig with pushInsteadOf rules so a `git push <upstream>`
from inside the agent transparently hits the per-agent git-gate
instead of the real remote. Fetch keeps the original URL — v1
gates push only.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from ....log import info
from ....manifest import GitEntry
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan
from ..git_gate import git_gate_host
def provision_git(plan: DockerBottlePlan, target: str) -> None:
"""Set up git inside the bottle. Runs both subcases; each no-ops
when its condition isn't met."""
_provision_cwd_git(plan, target)
_provision_git_gate_config(plan, target)
def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
"""If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op
otherwise."""
@@ -34,3 +53,48 @@ def provision_git(plan: DockerBottlePlan, target: str) -> None:
stdout=subprocess.DEVNULL,
check=True,
)
def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
"""Render the ~/.gitconfig content for git-gate `pushInsteadOf`
rewrites. Pure host-side, no docker; exposed for tests.
Empty `entries` returns an empty string so callers can no-op
cleanly without conditional formatting at the call site."""
if not entries:
return ""
gate = git_gate_host(slug)
out = [
"# claude-bottle git-gate (PRD 0008): pushes to declared upstreams\n",
"# transparently route through the gitleaks-scanning git-gate.\n",
"# Fetch keeps the original URL (v1 gates push only).\n",
]
for entry in entries:
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n')
out.append(f"\tpushInsteadOf = {entry.Upstream}\n")
return "".join(out)
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
"""Write ~/.gitconfig in the bottle with the git-gate
pushInsteadOf rules. No-op when the bottle has no `git` entries."""
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if not bottle.git:
return
container = target
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
container_gitconfig = f"{container_home}/.gitconfig"
content = render_git_gate_gitconfig(plan.slug, bottle.git)
config_file = plan.stage_dir / "agent_gitconfig"
config_file.write_text(content)
config_file.chmod(0o600)
info(f"writing {container_gitconfig} with {len(bottle.git)} pushInsteadOf rule(s)")
subprocess.run(
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig])
docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig])
+45
View File
@@ -0,0 +1,45 @@
"""Unit: render of ~/.gitconfig pushInsteadOf rules (PRD 0008)."""
import unittest
from claude_bottle.backend.docker.provision.git import render_git_gate_gitconfig
from tests.fixtures import fixture_minimal, fixture_with_git
class TestGitGateGitconfigRender(unittest.TestCase):
def test_empty_entries_renders_nothing(self):
bottle = fixture_minimal().bottles["dev"]
self.assertEqual("", render_git_gate_gitconfig("demo", bottle.git))
def test_one_block_per_entry(self):
bottle = fixture_with_git().bottles["dev"]
out = render_git_gate_gitconfig("demo", bottle.git)
# Both entries map to a [url ...] block keyed on the gate's
# container hostname (claude-bottle-git-gate-<slug>).
self.assertIn(
'[url "git://claude-bottle-git-gate-demo/claude-bottle.git"]',
out,
)
self.assertIn(
"\tpushInsteadOf = "
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
out,
)
self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out)
self.assertIn(
"\tpushInsteadOf = ssh://git@github.com/didericis/foo.git",
out,
)
def test_pushInsteadOf_not_insteadOf(self):
# insteadOf would route fetch through the gate too; v1 only
# gates push. If this assertion ever fails we've inadvertently
# widened the gate's scope.
bottle = fixture_with_git().bottles["dev"]
out = render_git_gate_gitconfig("demo", bottle.git)
self.assertIn("pushInsteadOf", out)
self.assertNotIn("\tinsteadOf", out)
if __name__ == "__main__":
unittest.main()