"""TDD — force push must use have anchors to send only new objects. Root cause ---------- When ``--force`` is passed, ``push.run`` clears all have anchors:: if force: have = [] # ← WRONG: sends every object in the local store Even on a force push the server already holds every object reachable from its OTHER branch heads (e.g. ``main``). Clearing ``have`` on force causes the client to walk the entire commit+object graph and re-upload every object the server has already stored, regardless of how little actually changed. Observable in production:: muse push local dev --force [stream] loaded 6695 objects ... ← all objects, even ones on main Fix --- Remove the ``if force: have = []`` special case. The non-force filter already does the right thing:: have = [c for c in candidate_have if c != local_head and _is_valid_commit_id(c) and commit_exists(root, c)] The ``commit_exists`` guard already excludes commits that don't exist locally (e.g. a diverged remote branch whose history the client never fetched), so the force-push safety concern about "missing ancestor objects" is handled automatically. No special casing needed. Test plan --------- A1 Force push dev when main already holds the shared objects — only the new object is sent, not the entire pool. A2 Force push when the remote has NO other branches — all objects sent (correct baseline: nothing to exclude). A3 Force push when remote dev is at a diverged HEAD (not in local store) — diverged HEAD is correctly excluded by commit_exists(), other branches are still used as anchors. B1 Normal branch → commit → merge → push workflow succeeds without force when remote dev was at the correct ancestor. """ from __future__ import annotations import datetime import json import pathlib import unittest.mock as mock from collections.abc import Mapping from unittest.mock import AsyncMock, MagicMock import pytest from muse._version import __version__ from muse.core.object_store import write_object from muse.core.pack import PushResult, RemoteInfo, collect_object_ids from muse.core.snapshot import compute_commit_id, compute_snapshot_id from muse.core.store import ( CommitRecord, SnapshotRecord, write_commit, write_snapshot, ) from muse.core.types import blob_id, long_id from muse.core.paths import ref_path, muse_dir from tests.cli_test_helper import CliRunner cli = None runner = CliRunner() _REPO_ID = "test-repo" _REMOTE_URL = "https://hub.example.com/repos/test-repo" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _bare_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: dot_muse = muse_dir(tmp_path) for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"): (dot_muse / d).mkdir(parents=True, exist_ok=True) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") (dot_muse / "repo.json").write_text( json.dumps({"repo_id": _REPO_ID, "schema_version": __version__, "domain": "code"}) ) (dot_muse / "config.toml").write_text( f'[remotes.origin]\nurl = "{_REMOTE_URL}"\n' ) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) return tmp_path def _write_obj(root: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(root, oid, content) return oid def _commit_with_manifest( root: pathlib.Path, manifest: dict[str, str], *, branch: str = "main", parent_id: str | None = None, idx: int = 0, ) -> CommitRecord: snap_id = compute_snapshot_id(manifest) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(hours=idx) parent_ids = [parent_id] if parent_id else [] cid = compute_commit_id( parent_ids=parent_ids, snapshot_id=snap_id, message=f"commit {branch} {idx}", committed_at_iso=ts.isoformat(), ) commit = CommitRecord( repo_id=_REPO_ID, commit_id=cid, branch=branch, snapshot_id=snap_id, message=f"commit {branch} {idx}", committed_at=ts, parent_commit_id=parent_id, ) write_commit(root, commit) (ref_path(root, branch)).write_text(cid) return commit def _push_result(branch: str, head: str) -> PushResult: return PushResult(ok=True, message="ok", branch_heads={branch: head}) def _transport_mock(branch_heads: Mapping[str, str], result_branch: str, result_head: str) -> MagicMock: transport = mock.MagicMock() transport.fetch_remote_info.return_value = RemoteInfo( domain="code", default_branch="main", branch_heads=branch_heads, ) transport.push_stream_coro = AsyncMock( return_value=_push_result(result_branch, result_head) ) return transport def _objects_sent(transport: MagicMock) -> set[str]: """Collect all object IDs sent across all push_stream_coro calls.""" ids: set[str] = set() for call in transport.push_stream_coro.call_args_list: for obj in call.kwargs.get("objects", []): ids.add(obj["object_id"]) return ids # --------------------------------------------------------------------------- # A — Force push object delta # --------------------------------------------------------------------------- class TestForcePushDelta: """Force push must not re-send objects already held by other remote branches.""" def test_a1_force_push_sends_only_new_object( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A1: Remote has 'main' with 20 shared objects. Force push 'dev' adds 1 new object. Only the new object should be sent across the wire. FAILS before fix because ``have=[]`` on force push causes all 21 objects to be collected by collect_object_ids. """ root = _bare_repo(tmp_path, monkeypatch) # 20 shared objects — already "on the server" via main shared_oids = [] for i in range(20): oid = _write_obj(root, f"shared-object-{i:03d}".encode()) shared_oids.append(oid) shared_manifest = {f"file{i:02d}.txt": oid for i, oid in enumerate(shared_oids)} main_commit = _commit_with_manifest(root, shared_manifest, branch="main", idx=0) # dev commit: same 20 shared objects + 1 new new_oid = _write_obj(root, b"brand-new-dev-only-object") dev_manifest = {**shared_manifest, "new.txt": new_oid} dev_commit = _commit_with_manifest(root, dev_manifest, branch="dev", idx=1) # Remote: main at main_commit (server holds 20 shared objects), # dev at some old diverged HEAD that doesn't exist locally. old_dev_head = long_id("d" * 64) transport = _transport_mock( branch_heads={"main": main_commit.commit_id, "dev": old_dev_head}, result_branch="dev", result_head=dev_commit.commit_id, ) with mock.patch("muse.cli.commands.push.make_transport", return_value=transport): result = runner.invoke( cli, ["push", "origin", "dev", "--force"], catch_exceptions=False ) assert result.exit_code == 0, result.output sent = _objects_sent(transport) assert new_oid in sent, "New object must be sent" re_sent = [oid for oid in shared_oids if oid in sent] assert re_sent == [], ( f"Force push must not re-send objects already on remote main.\n" f"Re-sent {len(re_sent)} of 20 shared objects: {[s[:12] for s in re_sent]}\n" f"Bug: 'if force: have = []' clears all have anchors, " "causing the full object graph to be walked." ) def test_a2_force_push_no_other_branches_sends_all( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A2: Remote has no other branches. Force push must send all local objects (nothing to exclude). Correct baseline — must pass before and after fix.""" root = _bare_repo(tmp_path, monkeypatch) oids = [_write_obj(root, f"obj-{i}".encode()) for i in range(5)] manifest = {f"f{i}.txt": oid for i, oid in enumerate(oids)} dev_commit = _commit_with_manifest(root, manifest, branch="dev", idx=0) # Remote: dev at a diverged HEAD, NO other branches old_dev_head = long_id("e" * 64) transport = _transport_mock( branch_heads={"dev": old_dev_head}, result_branch="dev", result_head=dev_commit.commit_id, ) with mock.patch("muse.cli.commands.push.make_transport", return_value=transport): result = runner.invoke( cli, ["push", "origin", "dev", "--force"], catch_exceptions=False ) assert result.exit_code == 0, result.output sent = _objects_sent(transport) for oid in oids: assert oid in sent, f"All objects must be sent when no other branches exist: {oid[:12]}" def test_a3_diverged_dev_head_excluded_other_branches_used( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A3: Remote dev HEAD is diverged (not in local store). The diverged HEAD must be excluded by commit_exists(); other branches (main) are still used as have anchors. This confirms the commit_exists() guard handles the safety concern that originally motivated 'if force: have = []'. """ root = _bare_repo(tmp_path, monkeypatch) # Shared objects on main shared_oids = [_write_obj(root, f"shared-{i}".encode()) for i in range(10)] main_manifest = {f"s{i}.txt": oid for i, oid in enumerate(shared_oids)} main_commit = _commit_with_manifest(root, main_manifest, branch="main", idx=0) # 1 new object on dev new_oid = _write_obj(root, b"only-on-dev") dev_manifest = {**main_manifest, "dev.txt": new_oid} dev_commit = _commit_with_manifest(root, dev_manifest, branch="dev", idx=1) # Remote: main is known, dev is diverged (commit not in local store) diverged_dev = long_id("f" * 64) # does NOT exist locally transport = _transport_mock( branch_heads={"main": main_commit.commit_id, "dev": diverged_dev}, result_branch="dev", result_head=dev_commit.commit_id, ) with mock.patch("muse.cli.commands.push.make_transport", return_value=transport): result = runner.invoke( cli, ["push", "origin", "dev", "--force"], catch_exceptions=False ) assert result.exit_code == 0, result.output sent = _objects_sent(transport) # diverged_dev excluded → new object sent, shared objects NOT re-sent assert new_oid in sent assert all(oid not in sent for oid in shared_oids), ( "Shared objects on main must be excluded even when dev HEAD is diverged" ) # --------------------------------------------------------------------------- # B — Normal branch → commit → merge → push (no force required) # --------------------------------------------------------------------------- class TestBranchMergePushNoForce: """The standard solo-dev workflow: branch off dev, work, merge back, push. No force should be required when the remote is at a known ancestor. """ def test_b1_push_after_branch_merge_succeeds_without_force( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """B1: Initial state: remote dev = commit A. Branch feat off A, commit B. Merge feat → dev (fast-forward: dev = B). Push dev → succeeds, no force. Documents the expected behavior and confirms no regression in the non-force code path. """ root = _bare_repo(tmp_path, monkeypatch) # Commit A — the base dev commit (already on remote) oid_a = _write_obj(root, b"commit-a-content") commit_a = _commit_with_manifest( root, {"a.txt": oid_a}, branch="dev", idx=0 ) # Commit B — feat branch commit (parent = A) oid_b = _write_obj(root, b"commit-b-new-content") commit_b = _commit_with_manifest( root, {"a.txt": oid_a, "b.txt": oid_b}, branch="dev", # fast-forward: dev now points here parent_id=commit_a.commit_id, idx=1, ) # Remote: dev is at A (the pre-branch state) transport = _transport_mock( branch_heads={"dev": commit_a.commit_id}, result_branch="dev", result_head=commit_b.commit_id, ) # Push WITHOUT --force with mock.patch("muse.cli.commands.push.make_transport", return_value=transport): result = runner.invoke( cli, ["push", "origin", "dev"], catch_exceptions=False ) assert result.exit_code == 0, ( f"Push after branch→merge should succeed without --force.\n" f"Output: {result.output}" ) # Only commit B should be in the push bundle (A was the remote HEAD) commits_sent = transport.push_stream_coro.call_args.kwargs["commits"] assert len(commits_sent) == 1, ( f"Only the new commit should be sent, got {len(commits_sent)}" ) assert commits_sent[0]["commit_id"] == commit_b.commit_id # Only the new object (b.txt) should be sent sent = _objects_sent(transport) assert oid_b in sent assert oid_a not in sent, "Shared object from base commit must not be re-sent" def test_b2_push_requires_force_when_dev_genuinely_diverged( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """B2: When local dev has genuinely diverged from remote (different history), the server correctly rejects with 409. Force is legitimately required. This documents that the 409 behavior is CORRECT — not a bug — when the histories have actually diverged (e.g. a rewrite or rename changed the local history and was never reconciled with the remote). """ root = _bare_repo(tmp_path, monkeypatch) oid = _write_obj(root, b"local-only-content") local_commit = _commit_with_manifest( root, {"local.txt": oid}, branch="dev", idx=0 ) # Remote dev HEAD is some totally different commit (diverged history) diverged_remote_head = long_id("a" * 64) # not an ancestor of local_commit transport = mock.MagicMock() transport.fetch_remote_info.return_value = RemoteInfo( domain="code", default_branch="main", branch_heads={"dev": diverged_remote_head}, ) # Server returns 409 for non-fast-forward push from muse.core.transport import TransportError transport.push_stream_coro = AsyncMock( return_value=PushResult( ok=False, message="non-fast-forward push to 'dev' — use force=true to overwrite", branch_heads={}, code=409, ) ) with mock.patch("muse.cli.commands.push.make_transport", return_value=transport): result = runner.invoke(cli, ["push", "origin", "dev"]) # Exit code 1 (push rejected) is expected assert result.exit_code != 0, ( "Push to a genuinely diverged remote must fail — force is required" ) # --------------------------------------------------------------------------- # C — Unit: collect_object_ids with have from non-ancestor (cross-branch) # --------------------------------------------------------------------------- class TestCollectObjectIdsCrossBranch: """collect_object_ids must exclude have-snapshot objects even when the have commit is NOT an ancestor of the new commits (diverged histories).""" def test_c1_have_objects_excluded_even_for_non_ancestor( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """C1: have commit is on a completely separate branch (not ancestor). Objects in its snapshot must still be excluded from the delta. This confirms the object-level dedup in collect_object_ids works independently of commit ancestry — which is the mechanism that makes the force push fix safe. """ root = _bare_repo(tmp_path, monkeypatch) # Object on 'main' branch (not ancestor of 'dev') main_obj = _write_obj(root, b"main-only-object") main_manifest = {"main.txt": main_obj} main_commit = _commit_with_manifest(root, main_manifest, branch="main", idx=0) # dev branch: shares main_obj + adds new_obj (NOT a child of main_commit) new_obj = _write_obj(root, b"dev-new-object") dev_manifest = {"main.txt": main_obj, "dev.txt": new_obj} dev_commit = _commit_with_manifest(root, dev_manifest, branch="dev", idx=0) # Verify these commits are NOT in an ancestor relationship # (both have no parent — they're independent roots) from muse.core.store import read_commit mc = read_commit(root, main_commit.commit_id) dc = read_commit(root, dev_commit.commit_id) assert mc.parent_commit_id is None assert dc.parent_commit_id is None # collect_object_ids with have=[main_commit]: # even though main is not an ancestor of dev, its snapshot objects # should still be subtracted result = collect_object_ids( root, [dev_commit.commit_id], have=[main_commit.commit_id] ) assert new_obj in result, "New dev object must be included" assert main_obj not in result, ( "Object already in have-snapshot (main) must be excluded " "even when main is not an ancestor of dev" )