PRD 0008: Git gate #11
@@ -6,10 +6,11 @@ Two concerns, both about git in the agent:
|
|||||||
into /home/node/workspace/.git so the agent operates on the
|
into /home/node/workspace/.git so the agent operates on the
|
||||||
user's repo.
|
user's repo.
|
||||||
2. If the bottle declares `git` entries (PRD 0008), write a
|
2. If the bottle declares `git` entries (PRD 0008), write a
|
||||||
~/.gitconfig with pushInsteadOf rules so a `git push <upstream>`
|
~/.gitconfig with insteadOf rules so every git operation
|
||||||
from inside the agent transparently hits the per-agent git-gate
|
against a declared upstream (push, fetch, clone, pull,
|
||||||
instead of the real remote. Fetch keeps the original URL — v1
|
ls-remote) transparently hits the per-agent git-gate. The
|
||||||
gates push only.
|
gate mirrors the upstream in both directions, so URL
|
||||||
|
rewriting is symmetric.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -56,7 +57,7 @@ def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
||||||
"""Render the ~/.gitconfig content for git-gate `pushInsteadOf`
|
"""Render the ~/.gitconfig content for git-gate `insteadOf`
|
||||||
rewrites. Pure host-side, no docker; exposed for tests.
|
rewrites. Pure host-side, no docker; exposed for tests.
|
||||||
|
|
||||||
Empty `entries` returns an empty string so callers can no-op
|
Empty `entries` returns an empty string so callers can no-op
|
||||||
@@ -65,19 +66,20 @@ def render_git_gate_gitconfig(slug: str, entries: tuple[GitEntry, ...]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
gate = git_gate_host(slug)
|
gate = git_gate_host(slug)
|
||||||
out = [
|
out = [
|
||||||
"# claude-bottle git-gate (PRD 0008): pushes to declared upstreams\n",
|
"# claude-bottle git-gate (PRD 0008): every git operation against\n",
|
||||||
"# transparently route through the gitleaks-scanning git-gate.\n",
|
"# a declared upstream routes through the gate, which mirrors\n",
|
||||||
"# Fetch keeps the original URL (v1 gates push only).\n",
|
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
||||||
|
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||||
]
|
]
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n')
|
out.append(f'[url "git://{gate}/{entry.Name}.git"]\n')
|
||||||
out.append(f"\tpushInsteadOf = {entry.Upstream}\n")
|
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
||||||
"""Write ~/.gitconfig in the bottle with the git-gate
|
"""Write ~/.gitconfig in the bottle with the git-gate
|
||||||
pushInsteadOf rules. No-op when the bottle has no `git` entries."""
|
insteadOf rules. No-op when the bottle has no `git` entries."""
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not bottle.git:
|
if not bottle.git:
|
||||||
return
|
return
|
||||||
@@ -90,7 +92,7 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
config_file.write_text(content)
|
config_file.write_text(content)
|
||||||
config_file.chmod(0o600)
|
config_file.chmod(0o600)
|
||||||
|
|
||||||
info(f"writing {container_gitconfig} with {len(bottle.git)} pushInsteadOf rule(s)")
|
info(f"writing {container_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
|
["docker", "cp", str(config_file), f"{container}:{container_gitconfig}"],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
|
|||||||
@@ -21,24 +21,25 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
|||||||
out,
|
out,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"\tpushInsteadOf = "
|
"\tinsteadOf = "
|
||||||
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
"ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
||||||
out,
|
out,
|
||||||
)
|
)
|
||||||
self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out)
|
self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"\tpushInsteadOf = ssh://git@github.com/didericis/foo.git",
|
"\tinsteadOf = ssh://git@github.com/didericis/foo.git",
|
||||||
out,
|
out,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_pushInsteadOf_not_insteadOf(self):
|
def test_insteadOf_not_pushInsteadOf(self):
|
||||||
# insteadOf would route fetch through the gate too; v1 only
|
# The gate mirrors fetch and push, so insteadOf (which rewrites
|
||||||
# gates push. If this assertion ever fails we've inadvertently
|
# both directions) is the right knob. pushInsteadOf would only
|
||||||
# widened the gate's scope.
|
# gate push and leave fetch on the original URL — exactly the
|
||||||
|
# v1 design we've moved past.
|
||||||
bottle = fixture_with_git().bottles["dev"]
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
out = render_git_gate_gitconfig("demo", bottle.git)
|
out = render_git_gate_gitconfig("demo", bottle.git)
|
||||||
self.assertIn("pushInsteadOf", out)
|
self.assertIn("\tinsteadOf", out)
|
||||||
self.assertNotIn("\tinsteadOf", out)
|
self.assertNotIn("pushInsteadOf", out)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user