From 689675160aa5843ad4f90886cb969adb12c7a3df Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 22:56:37 -0400 Subject: [PATCH] feat(manifest): add git_user bottle field (issue #86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-bottle `git config --global user.name` / `user.email` pair so the agent's commits inside the bottle land with a known identity rather than the agent image's default (no user, or whatever the image dropped in). Schema: git_user: name: "Eric Bauerfeld" email: "eric+claude@dideric.is" Either field can be set independently — name-only / email-only configs are valid and apply just the field that's set. An explicit `git_user:` block with both fields empty dies at parse time rather than silently no-op'ing; an omitted block is the no-op path (default GitUser is empty, provisioner skips). Parse-time validation: - Unknown sub-keys die (e.g., typo of `username`). - Non-string name/email dies. - Both-empty dies (half-finished edit hint). 11 unit tests in `test_manifest_git_user.py`; 653 unit tests pass. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/manifest.py | 66 +++++++++++++++- tests/unit/test_manifest_git_user.py | 109 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_manifest_git_user.py diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 6725fec..97a3a53 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -14,7 +14,9 @@ the system prompt, for bottles the body is human documentation Bottle schema (frontmatter): env: { : , ... } git: [ , ... ] + git_user: { name: , email: } # optional egress: { routes: [ , ... ] } + supervise: # optional Agent schema (frontmatter): bottle: # required @@ -157,6 +159,54 @@ EGRESS_SINGLETON_ROLES = frozenset({ }) +@dataclass(frozen=True) +class GitUser: + """Per-bottle `git config --global user.name` / `user.email` + pair (issue #86). The agent's commits inside the bottle are + attributed to this identity rather than the agent image's + image-baked default (no user, or whatever the image dropped + in). Either or both fields can be set independently. + + `from_dict` is forgiving on shape (a single missing field is + fine — we just skip that config line at provisioning) but + strict on types (string-or-die).""" + + name: str = "" + email: str = "" + + @classmethod + def from_dict(cls, bottle_name: str, raw: object) -> "GitUser": + d = _as_json_object(raw, f"bottle '{bottle_name}' git_user") + for k in d.keys(): + if k not in {"name", "email"}: + die( + f"bottle '{bottle_name}' git_user has unknown key {k!r}; " + f"allowed: name, email" + ) + name = d.get("name", "") + email = d.get("email", "") + if not isinstance(name, str): + die( + f"bottle '{bottle_name}' git_user.name must be a string " + f"(was {type(name).__name__})" + ) + if not isinstance(email, str): + die( + f"bottle '{bottle_name}' git_user.email must be a string " + f"(was {type(email).__name__})" + ) + if not name and not email: + die( + f"bottle '{bottle_name}' git_user is set but neither " + f"name nor email is non-empty; remove the block or " + f"fill at least one field." + ) + return cls(name=name, email=email) + + def is_empty(self) -> bool: + return not self.name and not self.email + + @dataclass(frozen=True) class EgressRoute: """One route on the per-bottle egress sidecar (PRD 0017). @@ -344,6 +394,12 @@ class EgressConfig: class Bottle: env: Mapping[str, str] = field(default_factory=_empty_str_dict) git: tuple[GitEntry, ...] = () + # Per-bottle git identity (issue #86). Empty default — bottles + # that don't set `git_user:` in the manifest skip the + # `git config --global` step entirely. Set independently of + # the `git:` upstream list above: a bottle can declare a user + # identity without any git-gate upstreams, and vice versa. + git_user: GitUser = field(default_factory=GitUser) egress: EgressConfig = field(default_factory=EgressConfig) # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # the launch step brings up a supervise sidecar that exposes three @@ -422,6 +478,12 @@ class Bottle: f"See docs/prds/0017-egress-via-mitmproxy.md." ) + git_user = ( + GitUser.from_dict(name, d["git_user"]) + if "git_user" in d + else GitUser() + ) + egress = ( EgressConfig.from_dict(name, d["egress"]) if "egress" in d @@ -436,7 +498,7 @@ class Bottle: ) return cls( - env=env, git=git, egress=egress, + env=env, git=git, git_user=git_user, egress=egress, supervise=supervise_raw, ) @@ -772,7 +834,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$") # sets dies with a "did you mean" pointer — typos shouldn't silently # ghost into an empty config. _BOTTLE_KEYS = frozenset( - {"env", "git", "egress", "supervise"} + {"env", "git", "git_user", "egress", "supervise"} ) _AGENT_KEYS_REQUIRED = frozenset({"bottle"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"}) diff --git a/tests/unit/test_manifest_git_user.py b/tests/unit/test_manifest_git_user.py new file mode 100644 index 0000000..bf950d5 --- /dev/null +++ b/tests/unit/test_manifest_git_user.py @@ -0,0 +1,109 @@ +"""Unit: Bottle.git_user manifest parsing + validation (issue #86).""" + +import contextlib +import io +import unittest + +from claude_bottle.log import Die +from claude_bottle.manifest import GitUser, Manifest + + +def _die_message(callable_, *args, **kwargs) -> str: + """Run `callable_` expecting it to die, return the stderr text + so tests can assert specifics. `die()` prints to stderr then + raises Die(1) — the exit code is in the exception, the human + message is in stderr.""" + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + try: + callable_(*args, **kwargs) + except Die: + return buf.getvalue() + raise AssertionError("expected Die was not raised") + + +def _manifest(git_user): + return { + "bottles": {"dev": {"git_user": git_user}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + } + + +class TestGitUserParsing(unittest.TestCase): + def test_parses_both_fields(self): + m = Manifest.from_json_obj(_manifest({ + "name": "Eric Bauerfeld", + "email": "eric+claude@dideric.is", + })) + u = m.bottles["dev"].git_user + self.assertEqual("Eric Bauerfeld", u.name) + self.assertEqual("eric+claude@dideric.is", u.email) + self.assertFalse(u.is_empty()) + + def test_name_only(self): + m = Manifest.from_json_obj(_manifest({"name": "Bot"})) + u = m.bottles["dev"].git_user + self.assertEqual("Bot", u.name) + self.assertEqual("", u.email) + + def test_email_only(self): + m = Manifest.from_json_obj(_manifest({"email": "bot@example.com"})) + u = m.bottles["dev"].git_user + self.assertEqual("", u.name) + self.assertEqual("bot@example.com", u.email) + + def test_omitted_defaults_to_empty(self): + # No git_user block at all → empty GitUser, is_empty True → + # provisioner skips the `git config` step entirely. + m = Manifest.from_json_obj({ + "bottles": {"dev": {}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + u = m.bottles["dev"].git_user + self.assertTrue(u.is_empty()) + + def test_both_empty_strings_dies(self): + # An explicit `git_user: {name: "", email: ""}` is a typo + # / half-finished edit; fail loudly rather than silently + # no-op (the operator clearly meant to configure something). + msg = _die_message( + Manifest.from_json_obj, _manifest({"name": "", "email": ""}), + ) + self.assertIn("neither name nor email", msg) + + def test_unknown_key_dies(self): + msg = _die_message( + Manifest.from_json_obj, + _manifest({"name": "Bot", "username": "bot"}), + ) + self.assertIn("unknown key", msg) + self.assertIn("username", msg) + + def test_non_string_name_dies(self): + msg = _die_message( + Manifest.from_json_obj, _manifest({"name": 42}), + ) + self.assertIn("git_user.name must be a string", msg) + + def test_non_string_email_dies(self): + msg = _die_message( + Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}), + ) + self.assertIn("git_user.email must be a string", msg) + + +class TestGitUserDirect(unittest.TestCase): + """Direct GitUser dataclass exercises (no manifest wrapper).""" + + def test_is_empty_default(self): + self.assertTrue(GitUser().is_empty()) + + def test_is_empty_false_when_name_set(self): + self.assertFalse(GitUser(name="x").is_empty()) + + def test_is_empty_false_when_email_set(self): + self.assertFalse(GitUser(email="x@y").is_empty()) + + +if __name__ == "__main__": + unittest.main()