diff --git a/claude_bottle/backend/docker/provision/git.py b/claude_bottle/backend/docker/provision/git.py index 991422b..7cd797a 100644 --- a/claude_bottle/backend/docker/provision/git.py +++ b/claude_bottle/backend/docker/provision/git.py @@ -1,6 +1,6 @@ """Git provisioning inside a running Docker bottle. -Two concerns, both about git in the agent: +Three concerns, all 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 @@ -11,6 +11,9 @@ Two concerns, both about git in the agent: ls-remote) transparently hits the per-agent git-gate. The gate mirrors the upstream in both directions, so URL rewriting is symmetric. + 3. If the bottle declares `git_user` (issue #86), set + `git config --global user.{name,email}` inside the bottle so + the agent's commits are attributed to that identity. """ from __future__ import annotations @@ -26,10 +29,11 @@ from ..bottle_plan import DockerBottlePlan 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.""" + """Set up git inside the bottle. Runs all three subcases; each + no-ops when its condition isn't met.""" _provision_cwd_git(plan, target) _provision_git_gate_config(plan, target) + _provision_git_user(plan, target) def _provision_cwd_git(plan: DockerBottlePlan, target: str) -> None: @@ -78,3 +82,40 @@ def _provision_git_gate_config(plan: DockerBottlePlan, target: str) -> None: ) docker_mod.docker_exec_root(container, ["chown", "node:node", container_gitconfig]) docker_mod.docker_exec_root(container, ["chmod", "644", container_gitconfig]) + + +def _provision_git_user(plan: DockerBottlePlan, target: str) -> None: + """Apply `git config --global user.{name,email}` inside the + bottle so the agent's commits are attributed to the operator- + chosen identity instead of the agent image's default + (which is no user — git would refuse to commit at all + until the agent ran its own `git config`). + + Runs as the `node` user so `--global` lands in + `/home/node/.gitconfig` (matching the existing + `_provision_git_gate_config` write location). No-op when the + bottle didn't declare `git_user`. + + Each field set independently — name-only or email-only + configs only run the `git config` line for the field + present.""" + bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) + gu = bottle.git_user + if gu.is_empty(): + return + if gu.name: + info(f"git config --global user.name = {gu.name!r}") + subprocess.run( + ["docker", "exec", "-u", "node", target, + "git", "config", "--global", "user.name", gu.name], + stdout=subprocess.DEVNULL, + check=True, + ) + if gu.email: + info(f"git config --global user.email = {gu.email!r}") + subprocess.run( + ["docker", "exec", "-u", "node", target, + "git", "config", "--global", "user.email", gu.email], + stdout=subprocess.DEVNULL, + check=True, + ) diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py new file mode 100644 index 0000000..9cd5728 --- /dev/null +++ b/tests/unit/test_docker_provision_git_user.py @@ -0,0 +1,140 @@ +"""Unit: docker backend `_provision_git_user` (issue #86). + +Mocks `subprocess.run` and asserts the `docker exec -u node … +git config --global …` argv shape. The cwd + git-gate passes +are covered indirectly by the existing integration-shaped tests +in test_smolmachines_provision; this file targets just the new +git_user pass.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from claude_bottle.backend import BottleSpec +from claude_bottle.backend.docker.bottle_plan import DockerBottlePlan +from claude_bottle.backend.docker.provision import git as _git +from claude_bottle.egress import EgressPlan +from claude_bottle.git_gate import GitGatePlan +from claude_bottle.manifest import Manifest +from claude_bottle.pipelock import PipelockProxyPlan + + +def _plan(*, git_user: dict | None = None, + stage_dir: Path | None = None) -> DockerBottlePlan: + bottle_json: dict = {} + if git_user is not None: + bottle_json["git_user"] = git_user + manifest = Manifest.from_json_obj({ + "bottles": {"dev": bottle_json}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + spec = BottleSpec( + manifest=manifest, agent_name="demo", + copy_cwd=False, user_cwd="/tmp/x", + ) + return DockerBottlePlan( + spec=spec, + stage_dir=stage_dir or Path("/tmp/stage"), + slug="demo-abc12", + container_name="claude-bottle-demo-abc12", + container_name_pinned=False, + image="claude-bottle:latest", + derived_image="", + runtime_image="claude-bottle:latest", + dockerfile_path="", + env_file=Path("/tmp/agent.env"), + forwarded_env={}, + prompt_file=Path("/tmp/prompt.txt"), + proxy_plan=PipelockProxyPlan( + yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", + ), + git_gate_plan=GitGatePlan( + slug="demo-abc12", + entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), + hook_script=Path("/tmp/git-gate-hook"), + access_hook_script=Path("/tmp/git-gate-access-hook"), + upstreams=(), + ), + egress_plan=EgressPlan( + slug="demo-abc12", + routes_path=Path("/tmp/routes.yaml"), + routes=(), + token_env_map={}, + ), + supervise_plan=None, + use_runsc=False, + ) + + +def _git_config_calls(mock_run) -> list[list[str]]: + """Filter `subprocess.run` calls down to the ones that run + `git config --global` inside the bottle, returning each argv.""" + out: list[list[str]] = [] + for call in mock_run.call_args_list: + argv = call.args[0] + if (len(argv) >= 5 + and argv[0] == "docker" and argv[1] == "exec" + and "git" in argv and "config" in argv): + out.append(list(argv)) + return out + + +class TestProvisionGitUser(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git-user.") + self.stage = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_noop_when_no_git_user(self): + with patch.object(_git.subprocess, "run") as run: + _git._provision_git_user( + _plan(stage_dir=self.stage), "claude-bottle-demo-abc12", + ) + self.assertEqual([], _git_config_calls(run)) + + def test_sets_name_and_email(self): + plan = _plan( + git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, + stage_dir=self.stage, + ) + with patch.object(_git.subprocess, "run") as run: + _git._provision_git_user(plan, "claude-bottle-demo-abc12") + calls = _git_config_calls(run) + self.assertEqual(2, len(calls)) + # All `docker exec` invocations run as `-u node` so the + # --global config lands in /home/node/.gitconfig. + for argv in calls: + self.assertEqual( + ["docker", "exec", "-u", "node", "claude-bottle-demo-abc12", + "git", "config", "--global"], + argv[:8], + ) + self.assertEqual(["user.name", "Eric Bauerfeld"], calls[0][8:]) + self.assertEqual(["user.email", "eric@dideric.is"], calls[1][8:]) + + def test_name_only_sets_only_name(self): + plan = _plan(git_user={"name": "Bot"}, stage_dir=self.stage) + with patch.object(_git.subprocess, "run") as run: + _git._provision_git_user(plan, "claude-bottle-demo-abc12") + calls = _git_config_calls(run) + self.assertEqual(1, len(calls)) + self.assertEqual(["user.name", "Bot"], calls[0][8:]) + + def test_email_only_sets_only_email(self): + plan = _plan( + git_user={"email": "bot@example.com"}, stage_dir=self.stage, + ) + with patch.object(_git.subprocess, "run") as run: + _git._provision_git_user(plan, "claude-bottle-demo-abc12") + calls = _git_config_calls(run) + self.assertEqual(1, len(calls)) + self.assertEqual(["user.email", "bot@example.com"], calls[0][8:]) + + +if __name__ == "__main__": + unittest.main()