"""TDD — push to a non-existent remote repo must fail loudly, not say "up to date". When ``fetch_remote_info`` returns HTTP 404 (repo not found on the remote), ``muse push`` currently swallows the error via ``_fetch_remote_info_safe``, falls back to stale local tracking refs, and exits 0 with ``status: up_to_date``. This is a silent data-loss-adjacent bug: the user believes the push succeeded, but nothing was sent. Git's UX: ERROR: Repository not found. fatal: Could not read from remote repository. Please make sure you have the correct access rights and the repository exists. Muse must match that behaviour: exit non-zero with a clear, actionable error. Tests ----- P404_1 (RED) fetch_remote_info raises TransportError(404) → exit non-zero, message includes "not found" or "does not exist". P404_2 (RED) --json output contains {"error": "repository_not_found", ...} with a non-zero exit_code field. P404_3 (GREEN) fetch_remote_info raises TransportError(0) (network failure) → existing fallback behaviour preserved; does NOT raise immediately. P404_4 (RED) fetch_remote_info raises TransportError(401) (auth) → exit non-zero with auth error, not silently swallowed. P404_5 (RED) _fetch_remote_info_safe re-raises on 404 (unit test for the private helper — the seam where the fix lives). """ from __future__ import annotations import argparse import datetime import json import pathlib from unittest.mock import MagicMock, patch import pytest from muse._version import __version__ from muse.core.mpack import RemoteInfo from muse.core.object_store import write_object from muse.core.paths import heads_dir, muse_dir, remotes_dir from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id from muse.core.commits import ( CommitRecord, write_commit, ) from muse.core.snapshots import ( SnapshotRecord, write_snapshot, ) from muse.core.transport import TransportError from muse.core.types import Manifest, blob_id from tests.cli_test_helper import CliRunner _PrintArg = str | int | float | bool | None _PrintKw = str | bool | None runner = CliRunner() cli = None # --------------------------------------------------------------------------- # Helpers shared with other push tests # --------------------------------------------------------------------------- def _bare_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: muse = muse_dir(tmp_path) for d in ("commits", "snapshots", "objects", "refs/heads", "remotes/origin"): (muse / d).mkdir(parents=True, exist_ok=True) (muse / "HEAD").write_text("ref: refs/heads/main\n") (muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/gabriel/myrepo"\n') monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) return tmp_path def _make_commit(root: pathlib.Path, label: str, parent_id: str | None = None) -> CommitRecord: raw = f"content-{label}".encode() oid = blob_id(raw) write_object(root, oid, raw) manifest: Manifest = {"file.txt": oid} snap_id = compute_snapshot_id(manifest) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) parent_ids = [parent_id] if parent_id else [] cid = compute_commit_id( parent_ids=parent_ids, snapshot_id=snap_id, message=f"commit {label}", committed_at_iso=committed_at.isoformat(), ) commit = CommitRecord( commit_id=cid, branch="main", snapshot_id=snap_id, message=f"commit {label}", committed_at=committed_at, parent_commit_id=parent_id, ) write_commit(root, commit) return commit def _transport_404() -> MagicMock: """Transport whose fetch_remote_info raises HTTP 404.""" t = MagicMock() t.fetch_remote_info.side_effect = TransportError( "HTTP 404: {\"detail\": \"repo 'gabriel/myrepo' not found\"}", status_code=404, ) return t def _transport_network_down() -> MagicMock: """Transport whose fetch_remote_info raises a connection error (status 0).""" t = MagicMock() t.fetch_remote_info.side_effect = TransportError("Connection refused", status_code=0) return t def _transport_401() -> MagicMock: """Transport whose fetch_remote_info raises HTTP 401 (auth failure).""" t = MagicMock() t.fetch_remote_info.side_effect = TransportError( "HTTP 401: Unauthorized", status_code=401 ) return t def _run_push(tmp_path: pathlib.Path, transport: MagicMock) -> tuple[int, str]: """Invoke muse push via run() and return (exit_code, stdout).""" from muse.cli.commands.push import run args = argparse.Namespace( remote="origin", branch="main", force=False, force_with_lease=False, delete=False, set_upstream_flag=False, dry_run=False, json_out=False, ) captured: list[str] = [] import builtins original_print = builtins.print def _capture(*a: _PrintArg, **kw: _PrintKw) -> None: file = kw.get("file") import sys if file is None or file is sys.stdout: captured.append(" ".join(str(x) for x in a)) original_print(*a, **kw) exit_code = 0 with ( patch("muse.cli.commands.push.require_repo", return_value=tmp_path), patch("muse.cli.commands.push.make_transport", return_value=transport), patch("muse.cli.commands.push.get_signing_identity", return_value=None), patch("builtins.print", side_effect=_capture), ): try: run(args) except SystemExit as e: exit_code = int(e.code) if e.code is not None else 0 return exit_code, "\n".join(captured) def _run_push_json(tmp_path: pathlib.Path, transport: MagicMock) -> tuple[int, dict]: """Invoke muse push --json and return (exit_code, parsed_json).""" from muse.cli.commands.push import run args = argparse.Namespace( remote="origin", branch="main", force=False, force_with_lease=False, delete=False, set_upstream_flag=False, dry_run=False, json_out=True, ) captured: list[str] = [] import builtins original_print = builtins.print def _capture(*a: _PrintArg, **kw: _PrintKw) -> None: import sys file = kw.get("file") if file is None or file is sys.stdout: captured.append(" ".join(str(x) for x in a)) original_print(*a, **kw) exit_code = 0 with ( patch("muse.cli.commands.push.require_repo", return_value=tmp_path), patch("muse.cli.commands.push.make_transport", return_value=transport), patch("muse.cli.commands.push.get_signing_identity", return_value=None), patch("builtins.print", side_effect=_capture), ): try: run(args) except SystemExit as e: exit_code = int(e.code) if e.code is not None else 0 output = json.loads("\n".join(captured)) if captured else {} return exit_code, output # ══════════════════════════════════════════════════════════════════════════════ # P404_1 — 404 exits non-zero with a "not found" message # ══════════════════════════════════════════════════════════════════════════════ def test_p404_1_repo_not_found_exits_nonzero( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """push to a non-existent repo (404) must exit non-zero. Currently RED: push silently swallows the 404, falls back to the locally-cached tracking ref (which matches local HEAD after migrate), and exits 0 with "up_to_date". """ root = _bare_repo(tmp_path, monkeypatch) commit = _make_commit(root, "c1") (heads_dir(root) / "main").write_text(commit.commit_id) # Simulate post-migrate state: local tracking ref updated to new commit ID. # This is exactly what causes the silent false-positive: remote_head == local_head. (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") exit_code, output = _run_push(root, _transport_404()) assert exit_code != 0, ( f"Expected non-zero exit for 404 (repo not found), got exit_code={exit_code}.\n" f"Output: {output!r}\n" "push must not silently return 'up_to_date' when the remote repo does not exist." ) # ══════════════════════════════════════════════════════════════════════════════ # P404_2 — --json output contains repository_not_found error # ══════════════════════════════════════════════════════════════════════════════ def test_p404_2_repo_not_found_json_error( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """push --json to a non-existent repo must emit {"error": "repository_not_found", ...}. Currently RED: the JSON output says {"status": "up_to_date"} with exit_code 0. """ root = _bare_repo(tmp_path, monkeypatch) commit = _make_commit(root, "c2") (heads_dir(root) / "main").write_text(commit.commit_id) (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") exit_code, data = _run_push_json(root, _transport_404()) assert exit_code != 0, f"Expected non-zero exit, got {exit_code}. JSON: {data}" assert data.get("status") != "up_to_date", ( "JSON must not report 'up_to_date' when the remote repo does not exist." ) assert "not_found" in data.get("error", "") or "not found" in str(data.get("message", "")).lower(), ( f"Expected 'not_found' in error field, got: {data}" ) # ══════════════════════════════════════════════════════════════════════════════ # P404_3 — network errors (status 0) still fall back gracefully # ══════════════════════════════════════════════════════════════════════════════ def test_p404_3_network_error_uses_cached_refs( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A connection-refused error (status_code=0) must NOT be treated like a 404. The existing fallback (use locally-cached tracking refs) must be preserved for transient network failures. This test stays GREEN — we must not regress it. """ root = _bare_repo(tmp_path, monkeypatch) commit = _make_commit(root, "c3") (heads_dir(root) / "main").write_text(commit.commit_id) # Write a cached tracking ref that matches local HEAD — simulates an # already-pushed state where the network is temporarily unreachable. (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") exit_code, output = _run_push(root, _transport_network_down()) # With cached ref == local head, push should say "up_to_date" (fallback works) assert exit_code == 0, ( f"Network failure (status 0) should fall back to cached refs and exit 0 " f"when already up to date. Got exit_code={exit_code}. Output: {output!r}" ) # ══════════════════════════════════════════════════════════════════════════════ # P404_4 — 401 auth errors are also not silenced # ══════════════════════════════════════════════════════════════════════════════ def test_p404_4_auth_error_exits_nonzero( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """push when fetch_remote_info returns 401 must exit non-zero, not fall back silently. Currently RED: 401 is swallowed by the same broad TransportError catch. """ root = _bare_repo(tmp_path, monkeypatch) commit = _make_commit(root, "c4") (heads_dir(root) / "main").write_text(commit.commit_id) (remotes_dir(root) / "origin" / "main").write_text(commit.commit_id + "\n") exit_code, output = _run_push(root, _transport_401()) assert exit_code != 0, ( f"Expected non-zero exit for 401 (auth failure), got exit_code={exit_code}.\n" f"Output: {output!r}\n" "Auth errors must not be silenced." ) # ══════════════════════════════════════════════════════════════════════════════ # P404_5 — unit test: _fetch_remote_info_safe re-raises on 404 # ══════════════════════════════════════════════════════════════════════════════ def test_p404_5_fetch_remote_info_safe_reraises_on_404() -> None: """_fetch_remote_info_safe must re-raise TransportError when status_code == 404. Currently RED: the function catches all TransportErrors and returns None. The fix: only swallow status_code == 0 (network-level failures); let HTTP error codes (404, 401, 409, 5xx) propagate so callers handle them. """ from muse.cli.commands.push import _fetch_remote_info_safe transport = MagicMock() transport.fetch_remote_info.side_effect = TransportError( "HTTP 404: repo not found", status_code=404 ) with pytest.raises(TransportError) as exc_info: _fetch_remote_info_safe(transport, "https://hub.example.com/r", None) assert exc_info.value.status_code == 404