From 58d76a50a626fe6afeeaccf8db64ff3d969fec7b Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 3 Jun 2026 03:55:07 +0000 Subject: [PATCH] test: update test suite for git-gate manifest redesign (PRD 0047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fixtures.py: fixture_with_git_dict uses git-gate.repos + url/identity/host_key - test_manifest_git: rewrite to use git-gate.repos; replace duplicate-name test (names = dict keys, always unique) with two-repos-different-hosts test - test_manifest_git_user: _manifest → git-gate.user; update error message assertions - test_manifest_agent_git_user: git → git-gate throughout; repos rejection test - test_manifest_extends: git.remotes/git.user → git-gate.repos/git-gate.user - test_provision_git: IP test updated — no host alias, single insteadOf - test_compose: git.remotes → git-gate.repos + new field names - test_docker_provision_git_user: git.user → git-gate.user - test_git_gate: inline manifest dict updated to git-gate.repos - test_smolmachines_provision: git_json → git_gate_json; remove _remote_host --- tests/fixtures.py | 24 +- tests/unit/test_compose.py | 9 +- tests/unit/test_docker_provision_git_user.py | 2 +- tests/unit/test_git_gate.py | 9 +- tests/unit/test_manifest_agent_git_user.py | 60 ++--- tests/unit/test_manifest_extends.py | 75 +++--- tests/unit/test_manifest_git.py | 267 ++++++++++--------- tests/unit/test_manifest_git_user.py | 10 +- tests/unit/test_provision_git.py | 19 +- tests/unit/test_smolmachines_provision.py | 22 +- 10 files changed, 234 insertions(+), 263 deletions(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index 4c73dc8..091ca5e 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -38,23 +38,21 @@ def fixture_with_egress_dict() -> dict[str, Any]: def fixture_with_git_dict() -> dict[str, Any]: - """Bottle declares a git-gate upstream. JSON shape.""" + """Bottle declares git-gate upstreams. JSON shape.""" return { "bottles": { "dev": { - "git": { - "remotes": { - "gitea.dideric.is": { - "Name": "bot-bottle", - "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", - "IdentityFile": "/dev/null", - "KnownHostKey": "ssh-ed25519 AAAA...", + "git-gate": { + "repos": { + "bot-bottle": { + "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", + "identity": "/dev/null", + "host_key": "ssh-ed25519 AAAA...", }, - "github.com": { - "Name": "foo", - "Upstream": "ssh://git@github.com/didericis/foo.git", - "IdentityFile": "/dev/null", - "KnownHostKey": "ssh-ed25519 BBBB...", + "foo": { + "url": "ssh://git@github.com/didericis/foo.git", + "identity": "/dev/null", + "host_key": "ssh-ed25519 BBBB...", }, }, } diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 87cc932..b566dec 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -49,11 +49,10 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest if supervise: bottle["supervise"] = True if with_git: - bottle["git"] = {"remotes": { - "example.com": { - "Name": "upstream", - "Upstream": "ssh://git@example.com:22/x/y.git", - "IdentityFile": "/etc/hostname", # any existing file + bottle["git-gate"] = {"repos": { + "upstream": { + "url": "ssh://git@example.com:22/x/y.git", + "identity": "/etc/hostname", # any existing file }, }} if with_egress: diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 763e4c1..5429a7d 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -30,7 +30,7 @@ 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} + bottle_json["git-gate"] = {"user": git_user} manifest = Manifest.from_json_obj({ "bottles": {"dev": bottle_json}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index af87309..c93462c 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -220,11 +220,10 @@ class TestPrepare(unittest.TestCase): def test_prepare_skips_known_hosts_file_when_key_missing(self): manifest = Manifest.from_json_obj({ - "bottles": {"dev": {"git": {"remotes": { - "github.com": { - "Name": "foo", - "Upstream": "ssh://git@github.com/didericis/foo.git", - "IdentityFile": "/dev/null", + "bottles": {"dev": {"git-gate": {"repos": { + "foo": { + "url": "ssh://git@github.com/didericis/foo.git", + "identity": "/dev/null", }, }}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, diff --git a/tests/unit/test_manifest_agent_git_user.py b/tests/unit/test_manifest_agent_git_user.py index 5558e1b..1e799e5 100644 --- a/tests/unit/test_manifest_agent_git_user.py +++ b/tests/unit/test_manifest_agent_git_user.py @@ -1,14 +1,14 @@ -"""Unit: agent-level git.user overlay + provenance (PRD 0027, issue #94). +"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047). -An agent file may declare `git.user` (name/email). At +An agent file may declare `git-gate.user` (name/email). At `Manifest.bottle_for()` it overlays the referenced bottle's -`git.user` per-field, agent-wins-on-non-empty. `git.remotes` is +`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is rejected on agents. `Manifest.git_identity_summary()` reports the effective identity with per-field `(agent)`/`(bottle)` provenance. The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`; a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the -`git` threading into `agent_dict`).""" +`git-gate` threading into `agent_dict`).""" from __future__ import annotations @@ -34,10 +34,10 @@ def _error_message(callable_, *args, **kwargs) -> str: def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: bottle: dict = {} if bottle_user is not None: - bottle = {"git": {"user": bottle_user}} + bottle = {"git-gate": {"user": bottle_user}} agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} if agent_git is not None: - agent["git"] = agent_git + agent["git-gate"] = agent_git return Manifest.from_json_obj({ "bottles": {"dev": bottle}, "agents": {"impl": agent}, @@ -71,7 +71,6 @@ class TestAgentGitUserOverlay(unittest.TestCase): def test_agent_identity_with_bottle_declaring_none(self): m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) - # The underlying bottle declares no identity; the merged one does. self.assertTrue(m.bottles["dev"].git_user.is_empty()) self.assertFalse(m.bottle_for("impl").git_user.is_empty()) @@ -82,14 +81,10 @@ class TestAgentGitUserOverlay(unittest.TestCase): self.assertEqual("b@c", u.email) def test_bottle_for_returns_same_instance_when_no_overlay(self): - # No agent git.user → no replace(); the cached Bottle is - # returned as-is (identity check guards against churn). m = _manifest(bottle_user={"name": "B"}) self.assertIs(m.bottles["dev"], m.bottle_for("impl")) def test_bottle_for_returns_same_instance_when_overlay_is_noop(self): - # Agent restates exactly what the bottle already has → merged - # == bottle.git_user → same instance, no replace(). m = _manifest( bottle_user={"name": "B", "email": "b@c"}, agent_git={"user": {"name": "B", "email": "b@c"}}, @@ -101,11 +96,11 @@ class TestAgentGitUserOverlay(unittest.TestCase): "bottles": {"dev": { "env": {"FOO": "bar"}, "supervise": True, - "git": {"user": {"name": "B"}}, + "git-gate": {"user": {"name": "B"}}, }}, "agents": {"impl": { "bottle": "dev", "skills": [], "prompt": "", - "git": {"user": {"name": "a"}}, + "git-gate": {"user": {"name": "a"}}, }}, }) b = m.bottle_for("impl") @@ -115,11 +110,11 @@ class TestAgentGitUserOverlay(unittest.TestCase): class TestAgentGitUserRejections(unittest.TestCase): - def test_agent_remotes_dies_bottle_only(self): + def test_agent_repos_dies_bottle_only(self): msg = _error_message(_manifest, agent_git={ - "remotes": {"h": {"Name": "r", "Upstream": "ssh://x/y.git"}}, + "repos": {"r": {"url": "ssh://git@x/y.git", "identity": "/dev/null"}}, }) - self.assertIn("git.remotes", msg) + self.assertIn("git-gate.repos", msg) self.assertIn("bottle-only", msg) def test_agent_unknown_git_subkey_dies(self): @@ -127,7 +122,6 @@ class TestAgentGitUserRejections(unittest.TestCase): self.assertIn("not allowed at the agent level", msg) def test_agent_git_user_both_empty_dies(self): - # Reuses GitUser.from_dict validation. msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}}) self.assertIn("neither name nor email", msg) @@ -164,7 +158,7 @@ class TestGitIdentitySummary(unittest.TestCase): _BOTTLE_DEV = """ --- - git: + git-gate: user: name: bottle-name email: bottle@example.com @@ -176,7 +170,7 @@ _BOTTLE_DEV = """ _AGENT_WITH_GIT = """ --- bottle: dev - git: + git-gate: user: name: agent-name --- @@ -184,14 +178,14 @@ _AGENT_WITH_GIT = """ impl agent. """ -_AGENT_WITH_REMOTES = """ +_AGENT_WITH_REPOS = """ --- bottle: dev - git: - remotes: - h: - Name: r - Upstream: ssh://x/y.git + git-gate: + repos: + r: + url: ssh://git@x/y.git + identity: /dev/null --- bad agent. @@ -199,9 +193,9 @@ _AGENT_WITH_REMOTES = """ class TestAgentGitUserMdLoader(unittest.TestCase): - """Locks the md path: `git` is an accepted agent key and threads - into the parsed Agent (not rejected as an unknown frontmatter - key), and agent `git.remotes` dies through the same loader.""" + """Locks the md path: `git-gate` is an accepted agent key and threads + into the parsed Agent (not rejected as an unknown frontmatter key), + and agent `git-gate.repos` dies through the same loader.""" def setUp(self) -> None: self.home = Path(tempfile.mkdtemp(prefix="cb-home-")) @@ -225,18 +219,18 @@ class TestAgentGitUserMdLoader(unittest.TestCase): self._write("agents/impl.md", _AGENT_WITH_GIT) m = Manifest.resolve(str(self.home)) u = m.bottle_for("impl").git_user - self.assertEqual("agent-name", u.name) # agent wins - self.assertEqual("bottle@example.com", u.email) # bottle falls through + self.assertEqual("agent-name", u.name) + self.assertEqual("bottle@example.com", u.email) self.assertEqual( "name=agent-name (agent), email=bottle@example.com (bottle)", m.git_identity_summary("impl"), ) - def test_md_agent_remotes_dies(self): + def test_md_agent_repos_dies(self): self._write("bottles/dev.md", _BOTTLE_DEV) - self._write("agents/impl.md", _AGENT_WITH_REMOTES) + self._write("agents/impl.md", _AGENT_WITH_REPOS) msg = _error_message(Manifest.resolve, str(self.home)) - self.assertIn("git.remotes", msg) + self.assertIn("git-gate.repos", msg) self.assertIn("bottle-only", msg) diff --git a/tests/unit/test_manifest_extends.py b/tests/unit/test_manifest_extends.py index f9461be..d45ddb0 100644 --- a/tests/unit/test_manifest_extends.py +++ b/tests/unit/test_manifest_extends.py @@ -113,42 +113,30 @@ class TestExtendsEnvMerge(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase): - """git.user overlays by field; git.remotes merges by upstream + """git-gate.user overlays by field; git-gate.repos merges by upstream host, with child entries replacing duplicate hosts.""" - _GIT_ENTRY_A = { - "Name": "a", - "Upstream": "ssh://git@host-a/a.git", - "IdentityFile": "/dev/null", - } - _GIT_ENTRY_B = { - "Name": "b", - "Upstream": "ssh://git@host-b/b.git", - "IdentityFile": "/dev/null", - } + _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"} + _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"} - def test_child_git_remotes_merge_with_parent(self): + def test_child_git_repos_merge_with_parent(self): m = _build( - base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, + base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, child={ "extends": "base", - "git": {"remotes": {"host-b": self._GIT_ENTRY_B}}, + "git-gate": {"repos": {"b": self._GIT_ENTRY_B}}, }, ) names = [e.Name for e in m.bottles["child"].git] self.assertEqual(["a", "b"], names) - def test_child_git_remote_replaces_same_host(self): - replacement = { - "Name": "a2", - "Upstream": "ssh://git@host-a/replacement.git", - "IdentityFile": "/dev/null", - } + def test_child_git_repo_replaces_same_host(self): + replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"} m = _build( - base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, + base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, child={ "extends": "base", - "git": {"remotes": {"host-a": replacement}}, + "git-gate": {"repos": {"a2": replacement}}, }, ) entries = m.bottles["child"].git @@ -156,30 +144,30 @@ class TestExtendsGitMerge(unittest.TestCase): self.assertEqual("a2", entries[0].Name) self.assertEqual("replacement.git", entries[0].UpstreamPath) - def test_child_omits_git_inherits_full_list(self): + def test_child_omits_git_gate_inherits_full_list(self): m = _build( - base={"git": {"remotes": { - "host-a": self._GIT_ENTRY_A, - "host-b": self._GIT_ENTRY_B, + base={"git-gate": {"repos": { + "a": self._GIT_ENTRY_A, + "b": self._GIT_ENTRY_B, }}}, child={"extends": "base"}, ) names = [e.Name for e in m.bottles["child"].git] self.assertEqual(["a", "b"], names) - def test_child_explicit_empty_git_clears_parent(self): - # `git.remotes: {}` is the documented way to say "drop - # the parent's remotes" rather than "inherit them". + def test_child_explicit_empty_repos_clears_parent(self): + # `git-gate.repos: {}` is the documented way to say "drop + # the parent's repos" rather than "inherit them". m = _build( - base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, - child={"extends": "base", "git": {"remotes": {}}}, + base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, + child={"extends": "base", "git-gate": {"repos": {}}}, ) self.assertEqual((), m.bottles["child"].git) - def test_child_git_user_inherits_parent_remotes(self): + def test_child_git_user_inherits_parent_repos(self): m = _build( - base={"git": {"remotes": {"host-a": self._GIT_ENTRY_A}}}, - child={"extends": "base", "git": {"user": {"name": "Child"}}}, + base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, + child={"extends": "base", "git-gate": {"user": {"name": "Child"}}}, ) self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git]) self.assertEqual("Child", m.bottles["child"].git_user.name) @@ -209,12 +197,12 @@ class TestExtendsListsFullReplace(unittest.TestCase): class TestExtendsGitUserOverlay(unittest.TestCase): - """git.user: per-field overlay. Each non-empty field on child + """git-gate.user: per-field overlay. Each non-empty field on child wins; empties fall through to parent.""" def test_parent_full_child_omits(self): m = _build( - base={"git": {"user": {"name": "Parent", "email": "p@x"}}}, + base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}}, child={"extends": "base"}, ) u = m.bottles["child"].git_user @@ -223,10 +211,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase): def test_child_overrides_both(self): m = _build( - base={"git": {"user": {"name": "Parent", "email": "p@x"}}}, + base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}}, child={ "extends": "base", - "git": {"user": {"name": "Child", "email": "c@x"}}, + "git-gate": {"user": {"name": "Child", "email": "c@x"}}, }, ) u = m.bottles["child"].git_user @@ -234,11 +222,9 @@ class TestExtendsGitUserOverlay(unittest.TestCase): self.assertEqual("c@x", u.email) def test_child_adds_email_inherits_name(self): - # Parent sets only name; child sets only email. Both end - # up populated on the child. m = _build( - base={"git": {"user": {"name": "Parent"}}}, - child={"extends": "base", "git": {"user": {"email": "c@x"}}}, + base={"git-gate": {"user": {"name": "Parent"}}}, + child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}}, ) u = m.bottles["child"].git_user self.assertEqual("Parent", u.name) @@ -246,11 +232,10 @@ class TestExtendsGitUserOverlay(unittest.TestCase): def test_child_overrides_only_email(self): m = _build( - base={"git": {"user": {"name": "Parent", "email": "p@x"}}}, - child={"extends": "base", "git": {"user": {"email": "c@x"}}}, + base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}}, + child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}}, ) u = m.bottles["child"].git_user - # Child overrides email; name inherited from parent. self.assertEqual("Parent", u.name) self.assertEqual("c@x", u.email) diff --git a/tests/unit/test_manifest_git.py b/tests/unit/test_manifest_git.py index 1c9036f..5422497 100644 --- a/tests/unit/test_manifest_git.py +++ b/tests/unit/test_manifest_git.py @@ -1,39 +1,25 @@ -"""Unit: Bottle.git manifest parsing + validation (PRD 0008).""" +"""Unit: git-gate.repos manifest parsing + validation (PRD 0047).""" import unittest from bot_bottle.manifest import ManifestError, Manifest -def _manifest(git_entries): +def _manifest(repos: dict) -> dict: return { - "bottles": {"dev": {"git": {"remotes": { - _host_for(entry): entry for entry in git_entries - }}}}, + "bottles": {"dev": {"git-gate": {"repos": repos}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, } -def _host_for(entry): - upstream = entry.get("Upstream", "") - if "@a.example" in upstream: - return "a.example" - if "@b.example" in upstream: - return "b.example" - if "@github.com" in upstream: - return "github.com" - if "@gitea.dideric.is" in upstream: - return "gitea.dideric.is" - return "example.com" - - class TestGitEntryParsing(unittest.TestCase): def test_parses_minimal_entry(self): - m = Manifest.from_json_obj(_manifest([{ - "Name": "bot-bottle", - "Upstream": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", - "IdentityFile": "/dev/null", - }])) + m = Manifest.from_json_obj(_manifest({ + "bot-bottle": { + "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", + "identity": "/dev/null", + }, + })) entries = m.bottles["dev"].git self.assertEqual(1, len(entries)) e = entries[0] @@ -44,138 +30,145 @@ class TestGitEntryParsing(unittest.TestCase): self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath) def test_default_port_is_22(self): - m = Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "ssh://git@github.com/didericis/foo.git", - "IdentityFile": "/dev/null", - }])) + m = Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://git@github.com/didericis/foo.git", + "identity": "/dev/null", + }, + })) e = m.bottles["dev"].git[0] self.assertEqual("22", e.UpstreamPort) self.assertEqual("github.com", e.UpstreamHost) - def test_known_host_key_optional(self): - m = Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "ssh://git@github.com/foo.git", - "IdentityFile": "/dev/null", - }])) + def test_host_key_optional(self): + m = Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://git@github.com/foo.git", + "identity": "/dev/null", + }, + })) self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey) - def test_missing_name_dies(self): - with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Upstream": "ssh://git@github.com/foo.git", - "IdentityFile": "/dev/null", - }])) + def test_host_key_stored(self): + m = Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://git@github.com/foo.git", + "identity": "/dev/null", + "host_key": "ssh-ed25519 AAAA", + }, + })) + self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey) - def test_missing_upstream_dies(self): - with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "IdentityFile": "/dev/null", - }])) + def test_repo_name_becomes_Name(self): + m = Manifest.from_json_obj(_manifest({ + "my-repo": { + "url": "ssh://git@github.com/foo.git", + "identity": "/dev/null", + }, + })) + self.assertEqual("my-repo", m.bottles["dev"].git[0].Name) - def test_missing_identity_file_dies(self): + def test_missing_url_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "ssh://git@github.com/foo.git", - }])) + Manifest.from_json_obj(_manifest({ + "foo": {"identity": "/dev/null"}, + })) - def test_non_ssh_upstream_dies(self): + def test_missing_identity_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "https://github.com/didericis/foo.git", - "IdentityFile": "/dev/null", - }])) + Manifest.from_json_obj(_manifest({ + "foo": {"url": "ssh://git@github.com/foo.git"}, + })) - def test_scp_style_upstream_dies(self): - # SCP-style "git@host:path" is intentionally not supported in - # v1 — ssh:// only. + def test_unknown_key_in_entry_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "git@github.com:didericis/foo.git", - "IdentityFile": "/dev/null", - }])) + Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://git@github.com/foo.git", + "identity": "/dev/null", + "IdentityFile": "/dev/null", # old PascalCase key + }, + })) - def test_upstream_without_user_dies(self): + def test_non_ssh_url_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "ssh://github.com/foo.git", - "IdentityFile": "/dev/null", - }])) + Manifest.from_json_obj(_manifest({ + "foo": { + "url": "https://github.com/didericis/foo.git", + "identity": "/dev/null", + }, + })) - def test_upstream_without_path_dies(self): + def test_scp_style_url_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "ssh://git@github.com", - "IdentityFile": "/dev/null", - }])) + Manifest.from_json_obj(_manifest({ + "foo": { + "url": "git@github.com:didericis/foo.git", + "identity": "/dev/null", + }, + })) + + def test_url_without_user_dies(self): + with self.assertRaises(ManifestError): + Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://github.com/foo.git", + "identity": "/dev/null", + }, + })) + + def test_url_without_path_dies(self): + with self.assertRaises(ManifestError): + Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://git@github.com", + "identity": "/dev/null", + }, + })) def test_non_numeric_port_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest([{ - "Name": "foo", - "Upstream": "ssh://git@github.com:notaport/foo.git", - "IdentityFile": "/dev/null", - }])) + Manifest.from_json_obj(_manifest({ + "foo": { + "url": "ssh://git@github.com:notaport/foo.git", + "identity": "/dev/null", + }, + })) + + def test_ip_literal_upstream(self): + m = Manifest.from_json_obj(_manifest({ + "bot-bottle": { + "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", + "identity": "/dev/null", + }, + })) + e = m.bottles["dev"].git[0] + self.assertEqual("100.78.141.42", e.UpstreamHost) + self.assertEqual("30009", e.UpstreamPort) + self.assertEqual("bot-bottle", e.Name) class TestGitEntryCrossValidation(unittest.TestCase): - def test_duplicate_name_dies(self): - with self.assertRaises(ManifestError): - Manifest.from_json_obj({ - "bottles": {"dev": {"git": {"remotes": { - "a.example": { - "Name": "foo", - "Upstream": "ssh://git@a.example/x.git", - "IdentityFile": "/dev/null", - }, - "b.example": { - "Name": "foo", - "Upstream": "ssh://git@b.example/y.git", - "IdentityFile": "/dev/null", - }, - }}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - - def test_remote_key_must_match_upstream_host(self): - with self.assertRaises(ManifestError): - Manifest.from_json_obj({ - "bottles": {"dev": {"git": {"remotes": { - "wrong.example": { - "Name": "foo", - "Upstream": "ssh://git@github.com/foo.git", - "IdentityFile": "/dev/null", - }, - }}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - - def test_remote_key_can_name_logical_host_for_ip_upstream(self): + def test_two_repos_different_hosts_both_parsed(self): + # Repo names come from dict keys; two distinct keys always produce + # two distinct entries (uniqueness is guaranteed at the YAML/dict level). m = Manifest.from_json_obj({ - "bottles": {"dev": {"git": {"remotes": { - "gitea.dideric.is": { - "Name": "bot-bottle", - "Upstream": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", - "IdentityFile": "/dev/null", + "bottles": {"dev": {"git-gate": {"repos": { + "foo": { + "url": "ssh://git@a.example/x.git", + "identity": "/dev/null", + }, + "bar": { + "url": "ssh://git@b.example/y.git", + "identity": "/dev/null", }, }}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) - e = m.bottles["dev"].git[0] - self.assertEqual("gitea.dideric.is", e.RemoteKey) - self.assertEqual("100.78.141.42", e.UpstreamHost) - self.assertEqual("30009", e.UpstreamPort) + names = {e.Name for e in m.bottles["dev"].git} + self.assertEqual({"foo", "bar"}, names) def test_legacy_ssh_field_dies_with_hint(self): - # PRD 0009: bottle.ssh is removed; manifests carrying it must - # fail loudly with a hint pointing at bottle.git. with self.assertRaises(ManifestError): Manifest.from_json_obj({ "bottles": { @@ -192,25 +185,37 @@ class TestGitEntryCrossValidation(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) + def test_legacy_git_key_dies_with_hint(self): + msg = "" + try: + Manifest.from_json_obj({ + "bottles": {"dev": {"git": {"remotes": {}}}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + except ManifestError as e: + msg = str(e) + self.assertIn("git-gate", msg) + self.assertIn("PRD 0047", msg) -class TestEmptyGitField(unittest.TestCase): - def test_no_git_field_yields_empty_tuple(self): + +class TestEmptyGitGateField(unittest.TestCase): + def test_no_git_gate_field_yields_empty_tuple(self): m = Manifest.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) self.assertEqual((), m.bottles["dev"].git) - def test_git_object_type_required(self): + def test_git_gate_object_type_required(self): with self.assertRaises(ManifestError): Manifest.from_json_obj({ - "bottles": {"dev": {"git": "not-a-list"}}, + "bottles": {"dev": {"git-gate": "not-a-dict"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) - def test_empty_remotes_yields_empty_tuple(self): + def test_empty_repos_yields_empty_tuple(self): m = Manifest.from_json_obj({ - "bottles": {"dev": {"git": {"remotes": {}}}}, + "bottles": {"dev": {"git-gate": {"repos": {}}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) self.assertEqual((), m.bottles["dev"].git) diff --git a/tests/unit/test_manifest_git_user.py b/tests/unit/test_manifest_git_user.py index 8b3bcb8..e4dc3a2 100644 --- a/tests/unit/test_manifest_git_user.py +++ b/tests/unit/test_manifest_git_user.py @@ -1,4 +1,4 @@ -"""Unit: Bottle git.user manifest parsing + validation (issue #86).""" +"""Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047).""" import unittest @@ -16,7 +16,7 @@ def _error_message(callable_, *args, **kwargs) -> str: def _manifest(git_user): return { - "bottles": {"dev": {"git": {"user": git_user}}}, + "bottles": {"dev": {"git-gate": {"user": git_user}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, } @@ -75,13 +75,13 @@ class TestGitUserParsing(unittest.TestCase): msg = _error_message( Manifest.from_json_obj, _manifest({"name": 42}), ) - self.assertIn("git.user.name must be a string", msg) + self.assertIn("git-gate.user.name must be a string", msg) def test_non_string_email_dies(self): msg = _error_message( Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}), ) - self.assertIn("git.user.email must be a string", msg) + self.assertIn("git-gate.user.email must be a string", msg) def test_legacy_top_level_git_user_dies(self): msg = _error_message( @@ -92,7 +92,7 @@ class TestGitUserParsing(unittest.TestCase): }, ) self.assertIn("git_user", msg) - self.assertIn("git.user", msg) + self.assertIn("git-gate.user", msg) class TestGitUserDirect(unittest.TestCase): diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index 7fd1c97..6794152 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -69,13 +69,14 @@ class TestGitGateGitconfigRender(unittest.TestCase): '[url "http://127.0.0.16:57001/bot-bottle.git"]', out, ) - def test_ip_upstream_also_rewrites_logical_remote_key(self): + def test_ip_upstream_emits_single_insteadof(self): + # In the new format the dict key is the repo name, not a host + # alias, so there is only one insteadOf rule — for the IP URL. m = Manifest.from_json_obj({ - "bottles": {"dev": {"git": {"remotes": { - "gitea.dideric.is": { - "Name": "bot-bottle", - "Upstream": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", - "IdentityFile": "/dev/null", + "bottles": {"dev": {"git-gate": {"repos": { + "bot-bottle": { + "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", + "identity": "/dev/null", }, }}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -86,11 +87,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", out, ) - self.assertIn( - "\tinsteadOf = " - "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", - out, - ) + self.assertNotIn("gitea.dideric.is", out) if __name__ == "__main__": diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index a6a2707..21046c5 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -42,11 +42,6 @@ from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan -def _remote_host(g: GitEntry) -> str: - if g.UpstreamHost: - return g.UpstreamHost - return g.Upstream.split("@", 1)[1].split("/", 1)[0].split(":", 1)[0] - def _plan( *, @@ -69,20 +64,19 @@ def _plan( guest_env: dict[str, str] | None = None, ) -> SmolmachinesBottlePlan: bottle_json: dict = {} - git_json: dict = {} + git_gate_json: dict = {} if git: - git_json["remotes"] = { - _remote_host(g): { - "Name": g.Name, - "Upstream": g.Upstream, - "IdentityFile": g.IdentityFile, + git_gate_json["repos"] = { + g.Name: { + "url": g.Upstream, + "identity": g.IdentityFile, } for g in git } if git_user is not None: - git_json["user"] = git_user - if git_json: - bottle_json["git"] = git_json + git_gate_json["user"] = git_user + if git_gate_json: + bottle_json["git-gate"] = git_gate_json if supervise: bottle_json["supervise"] = True manifest = Manifest.from_json_obj({