From 509b1b61e2e36627deb18626b8001c31e5d07f6f Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 12 May 2026 21:01:00 -0400 Subject: [PATCH] feat(git-gate): provision ~/.gitconfig pushInsteadOf in the bottle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' from inside the agent transparently rewrites to 'git:///.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. --- claude_bottle/backend/docker/provision/git.py | 72 +++++++++++++++++-- tests/unit/test_provision_git.py | 45 ++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_provision_git.py diff --git a/claude_bottle/backend/docker/provision/git.py b/claude_bottle/backend/docker/provision/git.py index 3007fac..937d151 100644 --- a/claude_bottle/backend/docker/provision/git.py +++ b/claude_bottle/backend/docker/provision/git.py @@ -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 ` + 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]) diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py new file mode 100644 index 0000000..16d1eb3 --- /dev/null +++ b/tests/unit/test_provision_git.py @@ -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-). + 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()