gabriel / muse public
test_phase4_dag_prune_push.py python
303 lines 11.6 KB
Raw
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