test_push_cli_flags.py
python
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