test_phase4_dag_prune_push.py
python
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d
feat(pack): delta-encode snapshots in MPackBundle wire format
Sonnet 4.6
minor
⚠ breaking
127 days ago
| 1 | """TDD — Phase 4: push-path prune gate and transport BFS consolidation. |
| 2 | |
| 3 | Phase 4 of issue #6 (generic DAG walker). |
| 4 | |
| 5 | Two atomic changes: |
| 6 | |
| 7 | Pack — prune gate |
| 8 | ----------------- |
| 9 | ``collect_object_ids`` and ``walk_commits`` replace ``exclude=have_set`` with |
| 10 | ``prune=lambda cid: cid in have_set`` in their ``iter_ancestors`` calls. |
| 11 | Effect: the walk stops at the server boundary without expanding have-commit |
| 12 | ancestors. Both ``exclude`` and ``prune`` are semantically equivalent for |
| 13 | reachability, but ``prune`` makes the early-termination intent explicit and |
| 14 | consistent with the ``walk_dag`` API used everywhere else. |
| 15 | |
| 16 | Transport — bundle-overlay adjacency closure |
| 17 | -------------------------------------------- |
| 18 | ``_is_ancestor`` in ``transport.py`` replaces its inline BFS |
| 19 | (``seen: set``, ``queue: list``, ``while queue``) with ``walk_dag``. |
| 20 | The adjacency function is a closure over ``bundle_by_id`` and |
| 21 | ``remote_root`` — it reads from the in-memory bundle first and falls back |
| 22 | to the on-disk store, which is the same two-source lookup the old loop did. |
| 23 | |
| 24 | Coverage |
| 25 | -------- |
| 26 | P4-1 Structural — ``collect_object_ids`` source contains ``prune=`` |
| 27 | P4-2 Structural — ``walk_commits`` source contains ``prune=`` |
| 28 | P4-3 Behavioural — walk stops at have boundary; objects from ancestors of |
| 29 | the have commit are NOT included in the result |
| 30 | P4-4 Structural — ``_is_ancestor`` uses ``walk_dag``; no inline BFS queue |
| 31 | P4-5 Behavioural — ``_is_ancestor`` returns True when candidate is in bundle |
| 32 | P4-6 Behavioural — ``_is_ancestor`` returns True when candidate is in store |
| 33 | P4-7 Behavioural — ``_is_ancestor`` returns False for unreachable commit |
| 34 | """ |
| 35 | from __future__ import annotations |
| 36 | |
| 37 | import datetime |
| 38 | import inspect |
| 39 | import json |
| 40 | import pathlib |
| 41 | |
| 42 | import pytest |
| 43 | |
| 44 | from muse._version import __version__ |
| 45 | from muse.core.object_store import write_object |
| 46 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id |
| 47 | from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot |
| 48 | from muse.core.types import blob_id |
| 49 | from muse.core.paths import muse_dir |
| 50 | |
| 51 | |
| 52 | # --------------------------------------------------------------------------- |
| 53 | # Repo fixture helpers |
| 54 | # --------------------------------------------------------------------------- |
| 55 | |
| 56 | def _repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 57 | dot_muse = muse_dir(tmp_path) |
| 58 | for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"): |
| 59 | (dot_muse / d).mkdir(parents=True, exist_ok=True) |
| 60 | (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 61 | (dot_muse / "repo.json").write_text( |
| 62 | json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) |
| 63 | ) |
| 64 | monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) |
| 65 | monkeypatch.chdir(tmp_path) |
| 66 | return tmp_path |
| 67 | |
| 68 | |
| 69 | def _write_obj(root: pathlib.Path, content: bytes) -> str: |
| 70 | oid = blob_id(content) |
| 71 | write_object(root, oid, content) |
| 72 | return oid |
| 73 | |
| 74 | |
| 75 | def _make_commit( |
| 76 | root: pathlib.Path, |
| 77 | manifest: dict[str, str], |
| 78 | parent_id: str | None = None, |
| 79 | ) -> CommitRecord: |
| 80 | snap_id = compute_snapshot_id(manifest) |
| 81 | write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) |
| 82 | ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) |
| 83 | cid = compute_commit_id( |
| 84 | parent_ids=[parent_id] if parent_id else [], |
| 85 | snapshot_id=snap_id, |
| 86 | message="test", |
| 87 | committed_at_iso=ts.isoformat(), |
| 88 | ) |
| 89 | rec = CommitRecord( |
| 90 | repo_id="test-repo", |
| 91 | commit_id=cid, |
| 92 | branch="main", |
| 93 | snapshot_id=snap_id, |
| 94 | message="test", |
| 95 | committed_at=ts, |
| 96 | parent_commit_id=parent_id, |
| 97 | ) |
| 98 | write_commit(root, rec) |
| 99 | return rec |
| 100 | |
| 101 | |
| 102 | # --------------------------------------------------------------------------- |
| 103 | # P4-1 Structural — collect_object_ids uses prune= |
| 104 | # --------------------------------------------------------------------------- |
| 105 | |
| 106 | def test_p4_1_collect_object_ids_uses_prune() -> None: |
| 107 | """collect_object_ids must use prune= in its iter_ancestors call. |
| 108 | |
| 109 | ``prune=lambda cid: cid in have_set`` makes early-termination intent |
| 110 | explicit. A plain ``exclude=have_set`` still works but diverges from |
| 111 | the canonical pattern used throughout graph.py. |
| 112 | """ |
| 113 | from muse.core import pack as pack_mod |
| 114 | |
| 115 | src = inspect.getsource(pack_mod.collect_object_ids) |
| 116 | assert "prune=" in src, ( |
| 117 | "collect_object_ids must pass prune= to iter_ancestors (or walk_dag). " |
| 118 | "Replace exclude=have_set with prune=lambda cid: cid in have_set." |
| 119 | ) |
| 120 | |
| 121 | |
| 122 | # --------------------------------------------------------------------------- |
| 123 | # P4-2 Structural — walk_commits uses prune= |
| 124 | # --------------------------------------------------------------------------- |
| 125 | |
| 126 | def test_p4_2_walk_commits_uses_prune() -> None: |
| 127 | """walk_commits must use prune= in its iter_ancestors call.""" |
| 128 | from muse.core import pack as pack_mod |
| 129 | |
| 130 | src = inspect.getsource(pack_mod.walk_commits) |
| 131 | assert "prune=" in src, ( |
| 132 | "walk_commits must pass prune= to iter_ancestors (or walk_dag). " |
| 133 | "Replace exclude=have_set with prune=lambda cid: cid in have_set." |
| 134 | ) |
| 135 | |
| 136 | |
| 137 | # --------------------------------------------------------------------------- |
| 138 | # P4-3 Behavioural — walk stops at have boundary |
| 139 | # --------------------------------------------------------------------------- |
| 140 | |
| 141 | def test_p4_3_collect_object_ids_stops_at_have_boundary( |
| 142 | tmp_path: pathlib.Path, |
| 143 | monkeypatch: pytest.MonkeyPatch, |
| 144 | ) -> None: |
| 145 | """Objects from commits older than the have boundary must not be returned. |
| 146 | |
| 147 | Chain: C1 (old) → C2 (have/server boundary) → C3 (new, local HEAD) |
| 148 | |
| 149 | Each commit changes the same file. C2 is the have commit the server |
| 150 | already has. collect_object_ids(root, [C3], have=[C2]) must return |
| 151 | only oid_c3 — the object from C3. It must NOT include oid_c1 (C1 is |
| 152 | an ancestor of the have boundary and must be pruned) and must NOT |
| 153 | include oid_c2 (C2 is the have commit itself). |
| 154 | """ |
| 155 | from muse.core.pack import collect_object_ids |
| 156 | |
| 157 | root = _repo(tmp_path, monkeypatch) |
| 158 | |
| 159 | oid_c1 = _write_obj(root, b"version-1") |
| 160 | oid_c2 = _write_obj(root, b"version-2") |
| 161 | oid_c3 = _write_obj(root, b"version-3") |
| 162 | |
| 163 | c1 = _make_commit(root, {"src/main.py": oid_c1}) |
| 164 | c2 = _make_commit(root, {"src/main.py": oid_c2}, parent_id=c1.commit_id) |
| 165 | c3 = _make_commit(root, {"src/main.py": oid_c3}, parent_id=c2.commit_id) |
| 166 | |
| 167 | result = collect_object_ids(root, [c3.commit_id], have=[c2.commit_id]) |
| 168 | |
| 169 | assert oid_c3 in result, "New object (C3's file version) must be in result" |
| 170 | assert oid_c2 not in result, ( |
| 171 | "Have-commit object must not be in result — server already has it" |
| 172 | ) |
| 173 | assert oid_c1 not in result, ( |
| 174 | "Ancestor-of-have object must not be in result — pruning must stop " |
| 175 | "the walk before reaching C1. If oid_c1 appears, the prune gate is " |
| 176 | "not firing correctly at the have boundary." |
| 177 | ) |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # P4-4 Structural — _is_ancestor uses walk_dag, not inline BFS |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | def test_p4_4_is_ancestor_uses_walk_dag() -> None: |
| 185 | """_is_ancestor must use walk_dag instead of an inline BFS queue. |
| 186 | |
| 187 | The inline pattern ``seen: set[str] = set(); queue: list[str] = [...]`` |
| 188 | followed by a ``while queue`` loop must not appear in ``_is_ancestor``. |
| 189 | Replace with ``walk_dag`` using a bundle-overlay adjacency closure. |
| 190 | """ |
| 191 | from muse.core import transport as transport_mod |
| 192 | |
| 193 | src = inspect.getsource(transport_mod._is_ancestor) # type: ignore[attr-defined] |
| 194 | |
| 195 | assert "walk_dag" in src, ( |
| 196 | "_is_ancestor must use walk_dag for its BFS traversal. " |
| 197 | "Replace the inline while-queue loop with walk_dag + adjacency closure." |
| 198 | ) |
| 199 | assert "while queue" not in src, ( |
| 200 | "_is_ancestor still has an inline while-queue BFS. " |
| 201 | "Replace with walk_dag." |
| 202 | ) |
| 203 | |
| 204 | |
| 205 | # --------------------------------------------------------------------------- |
| 206 | # P4-5 Behavioural — _is_ancestor: candidate found in bundle |
| 207 | # --------------------------------------------------------------------------- |
| 208 | |
| 209 | def test_p4_5_is_ancestor_finds_candidate_in_bundle( |
| 210 | tmp_path: pathlib.Path, |
| 211 | monkeypatch: pytest.MonkeyPatch, |
| 212 | ) -> None: |
| 213 | """_is_ancestor returns True when the candidate is in the bundle. |
| 214 | |
| 215 | Simulates the case where the fast-forward check needs to confirm the |
| 216 | remote tip (candidate) is reachable from the new push HEAD (from_commit), |
| 217 | and the remote tip is included in the push bundle. |
| 218 | """ |
| 219 | from muse.core.transport import _is_ancestor # type: ignore[attr-defined] |
| 220 | |
| 221 | root = _repo(tmp_path, monkeypatch) |
| 222 | |
| 223 | oid = _write_obj(root, b"data") |
| 224 | c1 = _make_commit(root, {"f.py": oid}) |
| 225 | c2 = _make_commit(root, {"f.py": oid}, parent_id=c1.commit_id) |
| 226 | c3 = _make_commit(root, {"f.py": oid}, parent_id=c2.commit_id) |
| 227 | |
| 228 | # Bundle contains c3 and c2; c1 is on disk (remote store). |
| 229 | bundle_by_id = { |
| 230 | c3.commit_id: c3.to_dict(), |
| 231 | c2.commit_id: c2.to_dict(), |
| 232 | } |
| 233 | |
| 234 | # c1 is reachable from c3 via the bundle path. |
| 235 | assert _is_ancestor(c1.commit_id, c3.commit_id, bundle_by_id, root) is True, ( |
| 236 | "_is_ancestor must return True when candidate is reachable through " |
| 237 | "commits that exist in the bundle." |
| 238 | ) |
| 239 | |
| 240 | |
| 241 | # --------------------------------------------------------------------------- |
| 242 | # P4-6 Behavioural — _is_ancestor: candidate found in store (not bundle) |
| 243 | # --------------------------------------------------------------------------- |
| 244 | |
| 245 | def test_p4_6_is_ancestor_finds_candidate_in_store( |
| 246 | tmp_path: pathlib.Path, |
| 247 | monkeypatch: pytest.MonkeyPatch, |
| 248 | ) -> None: |
| 249 | """_is_ancestor returns True when candidate is in store, not bundle. |
| 250 | |
| 251 | Simulates the typical push scenario: the new commits are in the bundle, |
| 252 | the existing remote commits (including the candidate remote tip) are only |
| 253 | on disk in the remote store. |
| 254 | """ |
| 255 | from muse.core.transport import _is_ancestor # type: ignore[attr-defined] |
| 256 | |
| 257 | root = _repo(tmp_path, monkeypatch) |
| 258 | |
| 259 | oid = _write_obj(root, b"data2") |
| 260 | c1 = _make_commit(root, {"g.py": oid}) |
| 261 | c2 = _make_commit(root, {"g.py": oid}, parent_id=c1.commit_id) |
| 262 | |
| 263 | # Bundle only contains c2 (the new push tip); c1 is on disk. |
| 264 | bundle_by_id = {c2.commit_id: c2.to_dict()} |
| 265 | |
| 266 | # c1 is reachable from c2 via store fallback. |
| 267 | assert _is_ancestor(c1.commit_id, c2.commit_id, bundle_by_id, root) is True, ( |
| 268 | "_is_ancestor must fall back to the store to find ancestors not in the bundle." |
| 269 | ) |
| 270 | |
| 271 | |
| 272 | # --------------------------------------------------------------------------- |
| 273 | # P4-7 Behavioural — _is_ancestor: returns False for unreachable commit |
| 274 | # --------------------------------------------------------------------------- |
| 275 | |
| 276 | def test_p4_7_is_ancestor_returns_false_for_unreachable( |
| 277 | tmp_path: pathlib.Path, |
| 278 | monkeypatch: pytest.MonkeyPatch, |
| 279 | ) -> None: |
| 280 | """_is_ancestor returns False when the candidate is not reachable. |
| 281 | |
| 282 | Two independent chains share no common ancestor. The candidate from |
| 283 | chain B must not be found when walking chain A. |
| 284 | """ |
| 285 | from muse.core.transport import _is_ancestor # type: ignore[attr-defined] |
| 286 | |
| 287 | root = _repo(tmp_path, monkeypatch) |
| 288 | |
| 289 | oid = _write_obj(root, b"data3") |
| 290 | # Chain A |
| 291 | a1 = _make_commit(root, {"a.py": oid}) |
| 292 | a2 = _make_commit(root, {"a.py": oid}, parent_id=a1.commit_id) |
| 293 | |
| 294 | # Chain B (independent) |
| 295 | oid2 = _write_obj(root, b"data4") |
| 296 | b1 = _make_commit(root, {"b.py": oid2}) |
| 297 | |
| 298 | bundle_by_id = {a2.commit_id: a2.to_dict()} |
| 299 | |
| 300 | assert _is_ancestor(b1.commit_id, a2.commit_id, bundle_by_id, root) is False, ( |
| 301 | "_is_ancestor must return False when the candidate is on an independent " |
| 302 | "chain unreachable from from_commit." |
| 303 | ) |
File History
1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d
feat(pack): delta-encode snapshots in MPackBundle wire format
Sonnet 4.6
minor
⚠
127 days ago