gabriel / muse public
test_clone_partial.py python
174 lines 6.3 KB
Raw
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
1 """TDD — clone with corrupt objects must exit PARTIAL (exit code 3), not INTERNAL_ERROR.
2
3 C1 When fetch_bundle delivers objects and some fail the integrity check, clone must:
4 - write the objects that are clean
5 - skip the corrupt ones with a warning
6 - exit with ExitCode.PARTIAL (3), not ExitCode.INTERNAL_ERROR (3 currently
7 mis-mapped to INTERNAL_ERROR)
8 - document this in the help text and ExitCode enum
9
10 C2 The --json envelope must include a "skipped_objects" count when > 0.
11 """
12 from __future__ import annotations
13
14 import json
15 import pathlib
16 import unittest.mock
17
18 import pytest
19
20 from muse.core.errors import ExitCode
21 from muse.core.mpack import MPack, BlobPayload, RemoteInfo
22 from muse.core.ids import hash_commit, hash_snapshot
23 from muse.core.types import blob_id, long_id
24 from tests.cli_test_helper import CliRunner
25
26 runner = CliRunner()
27 cli = None
28
29
30 def _make_remote_info(repo_id: str = "repo-partial") -> RemoteInfo:
31 return RemoteInfo(
32 repo_id=repo_id,
33 domain="code",
34 default_branch="main",
35 branch_heads={"main": long_id("c" * 64)},
36 )
37
38
39 def _make_bundle_with_corrupt_object() -> tuple[MPack, str, str]:
40 """Return (mpack, good_oid, corrupt_oid).
41
42 The mpack contains two objects:
43 - good_oid: content matches its ID
44 - corrupt_oid: content is wrong bytes (sha256 mismatch)
45 """
46 committed_at = "2026-01-01T00:00:00+00:00"
47
48 good_content = b"good content"
49 good_oid = blob_id(good_content)
50
51 corrupt_content = b"wrong bytes"
52 # Use an OID that does NOT match corrupt_content
53 corrupt_oid = blob_id(b"the real content that should be here")
54
55 snap_id = hash_snapshot({"good.txt": good_oid, "bad.txt": corrupt_oid})
56 cid = hash_commit(
57 parent_ids=[],
58 snapshot_id=snap_id,
59 message="test commit",
60 committed_at_iso=committed_at,
61 author="gabriel",
62 )
63 branch_heads = {"main": cid}
64
65 mpack = MPack(
66 commits=[{
67 "commit_id": cid,
68 "repo_id": "repo-partial",
69 "branch": "main",
70 "snapshot_id": snap_id,
71 "message": "test commit",
72 "committed_at": committed_at,
73 "parent_commit_id": None,
74 "parent2_commit_id": None,
75 "author": "gabriel",
76 "metadata": {},
77 "structured_delta": None,
78 "sem_ver_bump": "none",
79 "breaking_changes": [],
80 "agent_id": "",
81 "model_id": "",
82 "toolchain_id": "",
83 "prompt_hash": "",
84 "signature": "",
85 "signer_key_id": "",
86 "reviewed_by": [],
87 "test_runs": 0,
88 }],
89 snapshots=[{
90 "snapshot_id": snap_id,
91 "delta_upsert": {"good.txt": good_oid, "bad.txt": corrupt_oid},
92 "delta_remove": [],
93 "parent_snapshot_id": None,
94 "created_at": committed_at,
95 }],
96 blobs=[
97 BlobPayload(object_id=good_oid, content=good_content),
98 BlobPayload(object_id=corrupt_oid, content=corrupt_content),
99 ],
100 branch_heads=branch_heads,
101 )
102 return mpack, good_oid, corrupt_oid
103
104
105 def _mock_transport(info: RemoteInfo, mpack: MPack) -> unittest.mock.MagicMock:
106 mock = unittest.mock.MagicMock()
107 # Use the mpack's real content-addressed branch heads so apply_mpack and
108 # _restore_working_tree can find the commit after the fetch.
109 real_heads = mpack["branch_heads"]
110 patched_info = {**info, "branch_heads": real_heads}
111 mock.fetch_remote_info.return_value = patched_info
112 mock.fetch_mpack.return_value = {
113 "repo_id": info["repo_id"],
114 "domain": info["domain"],
115 "default_branch": info["default_branch"],
116 "branch_heads": real_heads,
117 "commits": mpack["commits"],
118 "snapshots": mpack["snapshots"],
119 "blobs": list(mpack["blobs"]),
120 "blobs_received": len(mpack["blobs"]),
121 "shallow_commits": [],
122 }
123 return mock
124
125
126 class TestClonePartial:
127 def test_exit_code_is_partial_when_objects_skipped(
128 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
129 ) -> None:
130 """Clone with a corrupt object must exit PARTIAL, not INTERNAL_ERROR."""
131 monkeypatch.chdir(tmp_path)
132 info = _make_remote_info()
133 mpack, good_oid, corrupt_oid = _make_bundle_with_corrupt_object()
134 mock = _mock_transport(info, mpack)
135
136 with unittest.mock.patch("muse.cli.commands.clone.make_transport", return_value=mock):
137 result = runner.invoke(cli, ["clone", "https://hub.example.com/repo-partial", "cloned"])
138
139 assert result.exit_code == ExitCode.PARTIAL
140
141 def test_good_object_is_written_despite_corrupt_sibling(
142 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
143 ) -> None:
144 """The clean object must be written even when another object is corrupt."""
145 monkeypatch.chdir(tmp_path)
146 info = _make_remote_info()
147 mpack, good_oid, corrupt_oid = _make_bundle_with_corrupt_object()
148 mock = _mock_transport(info, mpack)
149
150 with unittest.mock.patch("muse.cli.commands.clone.make_transport", return_value=mock):
151 runner.invoke(cli, ["clone", "https://hub.example.com/repo-partial", "cloned"])
152
153 from muse.core.object_store import has_object
154 cloned = tmp_path / "cloned"
155 assert has_object(cloned, good_oid), "clean object must be written"
156 assert not has_object(cloned, corrupt_oid), "corrupt object must not be written"
157
158 def test_json_envelope_includes_skipped_objects_count(
159 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
160 ) -> None:
161 """--json output must include skipped_objects count."""
162 monkeypatch.chdir(tmp_path)
163 info = _make_remote_info()
164 mpack, good_oid, corrupt_oid = _make_bundle_with_corrupt_object()
165 mock = _mock_transport(info, mpack)
166
167 with unittest.mock.patch("muse.cli.commands.clone.make_transport", return_value=mock):
168 result = runner.invoke(cli, ["clone", "https://hub.example.com/repo-partial", "cloned", "--json"])
169
170 lines = [l for l in result.output.strip().splitlines() if l.startswith("{")]
171 assert lines, "expected JSON output"
172 d = json.loads(lines[0])
173 assert "skipped_blobs" in d, "JSON envelope must include skipped_blobs"
174 assert d["skipped_blobs"] == 1
File History 1 commit
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago