gabriel / muse public
test_push_cli_flags.py python
390 lines 15.3 KB
Raw
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor ⚠ breaking 19 days ago
1 """TDD — CLI flag coverage for muse push.
2
3 Gap 4: --dry-run, --delete, --force-with-lease, --set-upstream are implemented
4 in run() but have no tests.
5
6 Test plan
7 ---------
8 DRY1 --dry-run exits 0 and makes no HTTP calls to the remote.
9 DRY2 --dry-run output includes commit and object count.
10 DRY3 --dry-run with no branch commits exits 1.
11 DEL1 --delete calls transport.delete_branch_remote with the branch name.
12 DEL2 --delete unknown branch (404 from remote) exits 0 with "already absent".
13 DEL3 --delete dry-run exits 0 without calling delete_branch_remote.
14 DEL4 --delete default-branch rejection (409 from remote) exits 1.
15 LEASE1 --force-with-lease rejected (exit 1) when remote has advanced since
16 last fetch (cached_head != live remote_head).
17 LEASE2 --force-with-lease proceeds when cached_head == live remote_head.
18 UP1 --set-upstream records tracking after successful push.
19 """
20 from __future__ import annotations
21
22 import datetime
23 import json
24 import pathlib
25 from unittest.mock import MagicMock, patch, AsyncMock
26
27 import msgpack
28 import pytest
29
30 from muse._version import __version__
31 from muse.core.mpack import PushResult, RemoteInfo
32 from muse.core.object_store import write_object
33 from muse.core.paths import heads_dir, muse_dir, remotes_dir
34 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
35
36 _Headers = dict[str, str] # HTTP header map
37 from muse.core.refs import get_head_commit_id
38 from muse.core.commits import (
39 CommitRecord,
40 write_commit,
41 )
42 from muse.core.snapshots import (
43 SnapshotRecord,
44 write_snapshot,
45 )
46 from muse.core.transport import TransportError
47 from muse.core.types import Manifest, blob_id
48 from tests.cli_test_helper import CliRunner
49
50
51 cli = None
52 runner = CliRunner()
53
54
55 # ---------------------------------------------------------------------------
56 # Helpers
57 # ---------------------------------------------------------------------------
58
59 def _bare_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
60 muse = muse_dir(tmp_path)
61 for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"):
62 (muse / d).mkdir(parents=True, exist_ok=True)
63 (muse / "HEAD").write_text("ref: refs/heads/main\n")
64 (muse / "repo.json").write_text(
65 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
66 )
67 (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/r"\n')
68 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
69 monkeypatch.chdir(tmp_path)
70 return tmp_path
71
72
73 def _make_commit(
74 root: pathlib.Path,
75 label: str,
76 parent_id: str | None = None,
77 content: bytes | None = None,
78 ) -> CommitRecord:
79 raw = content if content is not None else f"content-{label}".encode()
80 oid = blob_id(raw)
81 write_object(root, oid, raw)
82 manifest: Manifest = {"file.txt": oid}
83 snap_id = compute_snapshot_id(manifest)
84 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
85 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
86 parent_ids = [parent_id] if parent_id else []
87 cid = compute_commit_id(
88 parent_ids=parent_ids,
89 snapshot_id=snap_id,
90 message=f"commit {label}",
91 committed_at_iso=committed_at.isoformat(),
92 )
93 commit = CommitRecord(
94 commit_id=cid,
95 branch="main",
96 snapshot_id=snap_id,
97 message=f"commit {label}",
98 committed_at=committed_at,
99 parent_commit_id=parent_id,
100 )
101 write_commit(root, commit)
102 return commit
103
104
105 def _fake_resp(body: bytes, status: int = 200) -> MagicMock:
106 r = MagicMock()
107 r.status_code = status
108 r.content = body
109 r.headers = {"content-type": "application/x-msgpack"}
110 r.text = ""
111 return r
112
113
114 def _mpack_push_transport(local_head: str) -> MagicMock:
115 """Transport mock that makes _push_mpack succeed."""
116 transport = MagicMock()
117 transport.fetch_remote_info.return_value = RemoteInfo(
118 domain="code", default_branch="main", branch_heads={},
119 )
120 mock_req = MagicMock()
121 mock_req.headers = {"Authorization": "MSign stub", "Content-Type": "application/x-msgpack"}
122 transport._build_request.return_value = mock_req
123 return transport
124
125
126 def _fake_httpx_client(local_head: str) -> MagicMock:
127 """Fake httpx AsyncClient that makes _run_mpack_path succeed."""
128 client = MagicMock()
129 client.__aenter__ = AsyncMock(return_value=client)
130 client.__aexit__ = AsyncMock(return_value=False)
131
132 async def _post(url: str, *, content: bytes, headers: _Headers) -> MagicMock:
133 if "mpack-presign" in url:
134 return _fake_resp(msgpack.packb(
135 {"upload_url": "https://minio.example.com/put?sig=x"},
136 use_bin_type=True,
137 ))
138 return _fake_resp(msgpack.packb(
139 {"job_id": "j", "head": local_head, "branch": "main",
140 "objects_in_mpack": 0, "commits_in_mpack": 0},
141 use_bin_type=True,
142 ))
143
144 async def _put(url: str, *, content: bytes) -> MagicMock:
145 return _fake_resp(b"", 200)
146
147 client.post = _post
148 client.put = _put
149 return client
150
151
152 # ===========================================================================
153 # DRY — --dry-run
154 # ===========================================================================
155
156 class TestDryRun:
157 def test_dry1_no_http_calls(
158 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
159 ) -> None:
160 """--dry-run must exit 0 without opening any HTTP connection."""
161 root = _bare_repo(tmp_path, monkeypatch)
162 commit = _make_commit(root, "dry1")
163 (heads_dir(root) / "main").write_text(commit.commit_id)
164
165 transport = MagicMock()
166 transport.fetch_remote_info.side_effect = AssertionError(
167 "--dry-run must not call fetch_remote_info"
168 )
169
170 with patch("muse.cli.commands.push.make_transport", return_value=transport):
171 result = runner.invoke(cli, ["push", "origin", "--dry-run"], catch_exceptions=False)
172
173 assert result.exit_code == 0, result.output
174 transport.fetch_remote_info.assert_not_called()
175
176 def test_dry2_output_includes_counts(
177 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
178 ) -> None:
179 """--dry-run output must mention commit and object counts."""
180 root = _bare_repo(tmp_path, monkeypatch)
181 commit = _make_commit(root, "dry2")
182 (heads_dir(root) / "main").write_text(commit.commit_id)
183
184 with patch("muse.cli.commands.push.make_transport", return_value=MagicMock()):
185 result = runner.invoke(cli, ["push", "origin", "--dry-run"], catch_exceptions=False)
186
187 assert result.exit_code == 0, result.output
188 # The output must mention the dry-run nature
189 assert "dry" in result.output.lower() or "would" in result.output.lower(), (
190 f"Dry-run output should mention 'dry' or 'would': {result.output!r}"
191 )
192
193 def test_dry3_json_status_is_dry_run(
194 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
195 ) -> None:
196 """--dry-run --json must return status='dry_run'."""
197 root = _bare_repo(tmp_path, monkeypatch)
198 commit = _make_commit(root, "dry3")
199 (heads_dir(root) / "main").write_text(commit.commit_id)
200
201 with patch("muse.cli.commands.push.make_transport", return_value=MagicMock()):
202 result = runner.invoke(
203 cli, ["push", "origin", "--dry-run", "--json"], catch_exceptions=False
204 )
205
206 assert result.exit_code == 0, result.output
207 data = json.loads(result.output)
208 assert data["status"] == "dry_run"
209 assert data["dry_run"] is True
210
211
212 # ===========================================================================
213 # DEL — --delete
214 # ===========================================================================
215
216 class TestDelete:
217 def test_del1_calls_delete_branch_remote(
218 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
219 ) -> None:
220 """--delete must invoke transport.delete_branch_remote with the branch name."""
221 root = _bare_repo(tmp_path, monkeypatch)
222 _make_commit(root, "del1") # give main a commit so branch exists
223
224 transport = MagicMock()
225 transport.delete_branch_remote.return_value = None # success
226
227 with patch("muse.cli.commands.push.make_transport", return_value=transport):
228 result = runner.invoke(
229 cli, ["push", "origin", "main", "--delete"], catch_exceptions=False
230 )
231
232 assert result.exit_code == 0, result.output
233 transport.delete_branch_remote.assert_called_once()
234 _, args, _ = transport.delete_branch_remote.mock_calls[0]
235 # Second positional arg is the branch name
236 assert "main" in args or "main" in str(transport.delete_branch_remote.call_args)
237
238 def test_del2_404_treated_as_already_absent(
239 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
240 ) -> None:
241 """--delete on a branch that doesn't exist (404) must exit 0."""
242 root = _bare_repo(tmp_path, monkeypatch)
243
244 transport = MagicMock()
245 transport.delete_branch_remote.side_effect = TransportError("not found", 404)
246
247 with patch("muse.cli.commands.push.make_transport", return_value=transport):
248 result = runner.invoke(
249 cli, ["push", "origin", "stale-branch", "--delete"], catch_exceptions=False
250 )
251
252 assert result.exit_code == 0, f"404 delete should succeed: {result.output}"
253 assert "absent" in result.output.lower() or "already" in result.output.lower(), (
254 f"Output should note branch was already absent: {result.output!r}"
255 )
256
257 def test_del3_dry_run_skips_transport(
258 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
259 ) -> None:
260 """--delete --dry-run must exit 0 without calling delete_branch_remote."""
261 root = _bare_repo(tmp_path, monkeypatch)
262
263 transport = MagicMock()
264
265 with patch("muse.cli.commands.push.make_transport", return_value=transport):
266 result = runner.invoke(
267 cli, ["push", "origin", "my-branch", "--delete", "--dry-run"],
268 catch_exceptions=False,
269 )
270
271 assert result.exit_code == 0, result.output
272 transport.delete_branch_remote.assert_not_called()
273
274 def test_del4_default_branch_rejected(
275 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
276 ) -> None:
277 """--delete on the default branch (409 from server) must exit 1."""
278 root = _bare_repo(tmp_path, monkeypatch)
279
280 transport = MagicMock()
281 transport.delete_branch_remote.side_effect = TransportError("default branch", 409)
282
283 with patch("muse.cli.commands.push.make_transport", return_value=transport):
284 result = runner.invoke(
285 cli, ["push", "origin", "main", "--delete"], catch_exceptions=False
286 )
287
288 assert result.exit_code != 0, "Deleting default branch must exit non-zero"
289
290
291 # ===========================================================================
292 # LEASE — --force-with-lease
293 # ===========================================================================
294
295 class TestForceWithLease:
296 def test_lease1_rejected_when_remote_advanced(
297 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
298 ) -> None:
299 """--force-with-lease must exit 1 when the remote HEAD has advanced
300 past the locally cached tracking ref."""
301 root = _bare_repo(tmp_path, monkeypatch)
302 c1 = _make_commit(root, "lease-base")
303 c2 = _make_commit(root, "lease-local", parent_id=c1.commit_id)
304 c_remote_new = _make_commit(root, "remote-advanced", parent_id=c1.commit_id)
305 (heads_dir(root) / "main").write_text(c2.commit_id)
306
307 # Tracking ref says c1 was the last-fetched remote HEAD
308 origin_dir = remotes_dir(root) / "origin"
309 origin_dir.mkdir(parents=True, exist_ok=True)
310 (origin_dir / "main").write_text(c1.commit_id)
311
312 # Live remote reports c_remote_new (someone else pushed)
313 transport = MagicMock()
314 transport.fetch_remote_info.return_value = RemoteInfo(
315 domain="code", default_branch="main",
316 branch_heads={"main": c_remote_new.commit_id},
317 )
318
319 with patch("muse.cli.commands.push.make_transport", return_value=transport):
320 result = runner.invoke(
321 cli, ["push", "origin", "--force-with-lease"], catch_exceptions=False
322 )
323
324 assert result.exit_code != 0, (
325 "--force-with-lease must be rejected when remote advanced"
326 )
327
328 def test_lease2_proceeds_when_cache_matches_remote(
329 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
330 ) -> None:
331 """--force-with-lease proceeds when cached_head matches live remote_head."""
332 root = _bare_repo(tmp_path, monkeypatch)
333 c1 = _make_commit(root, "lease2-base")
334 c2 = _make_commit(root, "lease2-new", parent_id=c1.commit_id)
335 (heads_dir(root) / "main").write_text(c2.commit_id)
336
337 # Tracking ref says c1 — same as what the live remote reports
338 origin_dir = remotes_dir(root) / "origin"
339 origin_dir.mkdir(parents=True, exist_ok=True)
340 (origin_dir / "main").write_text(c1.commit_id)
341
342 transport = _mpack_push_transport(c2.commit_id)
343 transport.fetch_remote_info.return_value = RemoteInfo(
344 domain="code", default_branch="main",
345 branch_heads={"main": c1.commit_id}, # matches cached tracking ref
346 )
347 fake_client = _fake_httpx_client(c2.commit_id)
348
349 with (
350 patch("muse.cli.commands.push.make_transport", return_value=transport),
351 patch("muse.cli.commands.push._httpx.AsyncClient", return_value=fake_client),
352 patch("muse.cli.commands.push._make_r2_client", return_value=fake_client),
353 ):
354 result = runner.invoke(
355 cli, ["push", "origin", "--force-with-lease"], catch_exceptions=False
356 )
357
358 assert result.exit_code == 0, (
359 f"--force-with-lease must proceed when cache matches remote: {result.output}"
360 )
361
362
363 # ===========================================================================
364 # UP — --set-upstream
365 # ===========================================================================
366
367 class TestSetUpstream:
368 def test_up1_set_upstream_after_successful_push(
369 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
370 ) -> None:
371 """--set-upstream must record the upstream tracking relationship after push."""
372 root = _bare_repo(tmp_path, monkeypatch)
373 commit = _make_commit(root, "up1")
374 (heads_dir(root) / "main").write_text(commit.commit_id)
375
376 transport = _mpack_push_transport(commit.commit_id)
377 fake_client = _fake_httpx_client(commit.commit_id)
378
379 with (
380 patch("muse.cli.commands.push.make_transport", return_value=transport),
381 patch("muse.cli.commands.push._httpx.AsyncClient", return_value=fake_client),
382 patch("muse.cli.commands.push._make_r2_client", return_value=fake_client),
383 patch("muse.cli.commands.push.set_upstream") as mock_set_upstream,
384 ):
385 result = runner.invoke(
386 cli, ["push", "origin", "-u"], catch_exceptions=False
387 )
388
389 assert result.exit_code == 0, result.output
390 mock_set_upstream.assert_called_once_with("main", "origin", root)
File History 2 commits
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 19 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156 chore: bump version to 0.2.0rc11; fix typing audit violatio… Sonnet 4.6 minor 19 days ago