PRD 0008: Git gate #11

Merged
didericis merged 13 commits from git-gate into main 2026-05-12 23:16:45 -04:00
2 changed files with 113 additions and 4 deletions
Showing only changes of commit 509b1b61e2 - Show all commits
+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 Two concerns, both about git in the agent:
.git. The container-side path is fixed at /home/node/workspace/.git;
ownership is reset to node so the agent can run git commands.""" 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 from __future__ import annotations
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from ....log import info from ....log import info
from ....manifest import GitEntry
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
from ..git_gate import git_gate_host
def provision_git(plan: DockerBottlePlan, target: str) -> None: 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 """If --cwd was set and the host cwd has a .git directory, copy
it into /home/node/workspace/.git and fix ownership. No-op it into /home/node/workspace/.git and fix ownership. No-op
otherwise.""" otherwise."""
@@ -34,3 +53,48 @@ def provision_git(plan: DockerBottlePlan, target: str) -> None:
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
check=True, 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()