gabriel / muse public
test_core_graph.py python
400 lines 15.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for muse/core/graph.py — canonical commit DAG traversal primitives.
2
3 Coverage
4 --------
5 iter_ancestors
6 - empty starts list → yields nothing
7 - single root commit (no parents) → yields just that commit
8 - linear chain, yields in BFS order (newest-first)
9 - merge commit (two parents) → both parents visited, each once
10 - first_parent_only=True → only follows parent_commit_id
11 - exclude= set → treats boundary commits as already-visited
12 - max_commits=N → stops after N commits yielded (missing commits don't count)
13 - missing commit in chain → skipped gracefully, walk continues
14 - diamond DAG (shared ancestor) → shared commit visited exactly once
15 - multi-source starts → seeds BFS from multiple tips simultaneously
16 - uses deque internally (structural check, same rationale as blame test)
17
18 ancestor_ids
19 - returns set of commit IDs (sha256:-prefixed)
20 - empty repo → empty set
21 - respects exclude= boundaries
22 - respects max_commits cap (returns IDs of visited commits)
23
24 find_merge_base
25 - same commit → returns itself
26 - linear chain → returns the common ancestor
27 - true merge topology → returns LCA
28 - no common ancestor (two disjoint chains) → returns None
29 - max_ancestors= exceeded → raises ValueError
30 """
31
32 from __future__ import annotations
33
34 import datetime
35 import json
36 import pathlib
37
38 import pytest
39
40 from muse.core.graph import ancestor_ids, find_merge_base, iter_ancestors
41 from muse.core._types import long_id
42 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
43 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
44
45 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
46
47
48 # ---------------------------------------------------------------------------
49 # Helpers
50 # ---------------------------------------------------------------------------
51
52
53 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
54 muse = tmp_path / ".muse"
55 for d in ("objects", "commits", "snapshots", "refs/heads"):
56 (muse / d).mkdir(parents=True, exist_ok=True)
57 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
58 (muse / "HEAD").write_text("ref: refs/heads/main\n")
59 return tmp_path
60
61
62 def _write_commit(
63 repo: pathlib.Path,
64 message: str = "commit",
65 parent: str | None = None,
66 parent2: str | None = None,
67 author: str = "Author",
68 committed_at: datetime.datetime | None = None,
69 ) -> str:
70 """Write a commit and return its commit_id."""
71 dt = committed_at if committed_at is not None else _BASE_DT
72 snap_id = compute_snapshot_id({})
73 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest={}))
74 parent_ids = [p for p in (parent, parent2) if p is not None]
75 commit_id = compute_commit_id(
76 repo_id="test-repo",
77 parent_ids=parent_ids,
78 snapshot_id=snap_id,
79 message=message,
80 committed_at_iso=dt.isoformat(),
81 author=author,)
82 write_commit(
83 repo,
84 CommitRecord(
85 commit_id=commit_id,
86 repo_id="test-repo",
87 created_on_branch="main",
88 snapshot_id=snap_id,
89 message=message,
90 committed_at=dt,
91 parent_commit_id=parent,
92 author=author,
93 parent2_commit_id=parent2,
94 ),
95 )
96 return commit_id
97
98
99 def _ids(commits) -> list[str]:
100 return [c.commit_id for c in commits]
101
102
103 # ---------------------------------------------------------------------------
104 # iter_ancestors
105 # ---------------------------------------------------------------------------
106
107
108 class TestIterAncestors:
109 def test_empty_starts_yields_nothing(self, tmp_path: pathlib.Path) -> None:
110 repo = _make_repo(tmp_path)
111 assert list(iter_ancestors(repo, [])) == []
112
113 def test_single_root_commit(self, tmp_path: pathlib.Path) -> None:
114 repo = _make_repo(tmp_path)
115 cid = _write_commit(repo, "root")
116 result = list(iter_ancestors(repo, cid))
117 assert len(result) == 1
118 assert result[0].commit_id == cid
119
120 def test_linear_chain_yields_all_in_bfs_order(self, tmp_path: pathlib.Path) -> None:
121 """A→B→C chain starting from A yields A, B, C in that order."""
122 repo = _make_repo(tmp_path)
123 c = _write_commit(repo, "root")
124 b = _write_commit(repo, "second", parent=c)
125 a = _write_commit(repo, "third", parent=b)
126
127 result = _ids(iter_ancestors(repo, a))
128 assert result == [a, b, c]
129
130 def test_merge_commit_visits_both_parents(self, tmp_path: pathlib.Path) -> None:
131 """Merge commit with two parents → both parent branches visited."""
132 repo = _make_repo(tmp_path)
133 root = _write_commit(repo, "root")
134 left = _write_commit(repo, "left", parent=root)
135 right = _write_commit(repo, "right", parent=root)
136 merge = _write_commit(repo, "merge", parent=left, parent2=right)
137
138 visited = set(_ids(iter_ancestors(repo, merge)))
139 assert merge in visited
140 assert left in visited
141 assert right in visited
142 assert root in visited
143
144 def test_diamond_dag_shared_ancestor_visited_once(self, tmp_path: pathlib.Path) -> None:
145 """In a diamond (A→B, A→C, B→D, C→D), D is visited exactly once."""
146 repo = _make_repo(tmp_path)
147 d = _write_commit(repo, "D")
148 b = _write_commit(repo, "B", parent=d)
149 c = _write_commit(repo, "C", parent=d)
150 a = _write_commit(repo, "A", parent=b, parent2=c)
151
152 result = list(iter_ancestors(repo, a))
153 commit_ids = _ids(result)
154 # D must appear exactly once despite two paths reaching it.
155 assert commit_ids.count(d) == 1
156 assert set(commit_ids) == {a, b, c, d}
157
158 def test_first_parent_only_skips_second_parent(self, tmp_path: pathlib.Path) -> None:
159 repo = _make_repo(tmp_path)
160 root = _write_commit(repo, "root")
161 left = _write_commit(repo, "left", parent=root)
162 right = _write_commit(repo, "right", parent=root)
163 merge = _write_commit(repo, "merge", parent=left, parent2=right)
164
165 result = set(_ids(iter_ancestors(repo, merge, first_parent_only=True)))
166 assert merge in result
167 assert left in result
168 assert root in result
169 assert right not in result # second parent branch skipped
170
171 def test_exclude_stops_at_boundary(self, tmp_path: pathlib.Path) -> None:
172 """Commits in `exclude` and their ancestors are never yielded."""
173 repo = _make_repo(tmp_path)
174 root = _write_commit(repo, "root")
175 mid = _write_commit(repo, "mid", parent=root)
176 tip = _write_commit(repo, "tip", parent=mid)
177
178 result = _ids(iter_ancestors(repo, tip, exclude={mid}))
179 assert tip in result
180 assert mid not in result
181 assert root not in result
182
183 def test_max_commits_caps_yield(self, tmp_path: pathlib.Path) -> None:
184 """max_commits=2 yields at most 2 commits from a longer chain."""
185 repo = _make_repo(tmp_path)
186 c = _write_commit(repo, "root")
187 b = _write_commit(repo, "mid", parent=c)
188 a = _write_commit(repo, "tip", parent=b)
189
190 result = list(iter_ancestors(repo, a, max_commits=2))
191 assert len(result) == 2
192
193 def test_missing_commit_skipped_walk_continues(self, tmp_path: pathlib.Path) -> None:
194 """A missing commit ID in the chain is silently skipped."""
195 repo = _make_repo(tmp_path)
196 root = _write_commit(repo, "root")
197 # Write a commit that references a nonexistent parent.
198 snap_id = compute_snapshot_id({})
199 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest={}))
200 ghost_parent = long_id("ab" * 32)
201 cid = compute_commit_id(
202 repo_id="test-repo",
203 parent_ids=[ghost_parent],
204 snapshot_id=snap_id,
205 message="orphan",
206 committed_at_iso=_BASE_DT.isoformat(),
207 author="A",)
208 write_commit(
209 repo,
210 CommitRecord(
211 commit_id=cid,
212 repo_id="test-repo",
213 created_on_branch="main",
214 snapshot_id=snap_id,
215 message="orphan",
216 committed_at=_BASE_DT,
217 parent_commit_id=ghost_parent,
218 author="A",
219 ),
220 )
221
222 # Should yield the orphan commit without crashing on the ghost parent.
223 result = list(iter_ancestors(repo, cid))
224 assert len(result) == 1
225 assert result[0].commit_id == cid
226
227 def test_multi_source_starts(self, tmp_path: pathlib.Path) -> None:
228 """Multi-source BFS from two tips collects ancestors of both."""
229 repo = _make_repo(tmp_path)
230 shared = _write_commit(repo, "shared")
231 branch_a = _write_commit(repo, "branch_a", parent=shared)
232 branch_b = _write_commit(repo, "branch_b", parent=shared)
233
234 visited = set(_ids(iter_ancestors(repo, [branch_a, branch_b])))
235 assert branch_a in visited
236 assert branch_b in visited
237 assert shared in visited # shared ancestor visited once
238
239 def test_multi_source_shared_ancestor_once(self, tmp_path: pathlib.Path) -> None:
240 """Shared ancestor appears exactly once in multi-source BFS."""
241 repo = _make_repo(tmp_path)
242 shared = _write_commit(repo, "shared")
243 a = _write_commit(repo, "a", parent=shared)
244 b = _write_commit(repo, "b", parent=shared)
245
246 all_ids = _ids(iter_ancestors(repo, [a, b]))
247 assert all_ids.count(shared) == 1
248
249 def test_yields_commit_records_not_ids(self, tmp_path: pathlib.Path) -> None:
250 """iter_ancestors yields CommitRecord objects (not strings)."""
251 from muse.core.store import CommitRecord as CR
252 repo = _make_repo(tmp_path)
253 cid = _write_commit(repo, "root", author="Alice")
254 result = list(iter_ancestors(repo, cid))
255 assert len(result) == 1
256 assert isinstance(result[0], CR)
257 assert result[0].author == "Alice"
258
259 def test_uses_deque_not_list(self) -> None:
260 """iter_ancestors must use collections.deque for O(1) popleft."""
261 import inspect
262 from muse.core import graph as graph_module
263 source = inspect.getsource(graph_module.iter_ancestors)
264 assert "deque" in source, "iter_ancestors must use collections.deque"
265 assert "pop(0)" not in source, "must not use list.pop(0)"
266 assert "insert(0" not in source, "must not use list.insert(0, ...)"
267
268
269 # ---------------------------------------------------------------------------
270 # ancestor_ids
271 # ---------------------------------------------------------------------------
272
273
274 class TestAncestorIds:
275 def test_returns_set_of_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
276 repo = _make_repo(tmp_path)
277 c = _write_commit(repo, "root")
278 b = _write_commit(repo, "mid", parent=c)
279 a = _write_commit(repo, "tip", parent=b)
280 result = ancestor_ids(repo, a)
281 assert isinstance(result, set)
282 assert result == {a, b, c}
283 for oid in result:
284 assert oid.startswith("sha256:")
285
286 def test_empty_starts_returns_empty_set(self, tmp_path: pathlib.Path) -> None:
287 repo = _make_repo(tmp_path)
288 assert ancestor_ids(repo, []) == set()
289
290 def test_exclude_boundaries_respected(self, tmp_path: pathlib.Path) -> None:
291 repo = _make_repo(tmp_path)
292 root = _write_commit(repo, "root")
293 mid = _write_commit(repo, "mid", parent=root)
294 tip = _write_commit(repo, "tip", parent=mid)
295 result = ancestor_ids(repo, tip, exclude={mid})
296 assert tip in result
297 assert mid not in result
298 assert root not in result
299
300 def test_max_commits_respected(self, tmp_path: pathlib.Path) -> None:
301 repo = _make_repo(tmp_path)
302 ids = []
303 prev = None
304 for i in range(10):
305 cid = _write_commit(repo, f"c{i}", parent=prev)
306 ids.append(cid)
307 prev = cid
308 tip = ids[-1]
309 result = ancestor_ids(repo, tip, max_commits=5)
310 assert len(result) <= 5
311
312 def test_range_exclusion_pattern(self, tmp_path: pathlib.Path) -> None:
313 """ancestor_ids(base) as exclude= produces A..B semantics."""
314 repo = _make_repo(tmp_path)
315 shared = _write_commit(repo, "shared")
316 base = _write_commit(repo, "base", parent=shared)
317 c1 = _write_commit(repo, "c1", parent=base)
318 c2 = _write_commit(repo, "c2", parent=c1)
319
320 base_ancestors = ancestor_ids(repo, base)
321 tip_range = ancestor_ids(repo, c2, exclude=base_ancestors)
322 assert c2 in tip_range
323 assert c1 in tip_range
324 assert base not in tip_range
325 assert shared not in tip_range
326
327
328 # ---------------------------------------------------------------------------
329 # find_merge_base
330 # ---------------------------------------------------------------------------
331
332
333 class TestFindMergeBase:
334 def test_same_commit_returns_itself(self, tmp_path: pathlib.Path) -> None:
335 repo = _make_repo(tmp_path)
336 cid = _write_commit(repo, "root")
337 assert find_merge_base(repo, cid, cid) == cid
338
339 def test_linear_chain_returns_common_ancestor(self, tmp_path: pathlib.Path) -> None:
340 """A→B→C: merge_base(A, B) == B."""
341 repo = _make_repo(tmp_path)
342 c = _write_commit(repo, "root")
343 b = _write_commit(repo, "mid", parent=c)
344 a = _write_commit(repo, "tip", parent=b)
345 assert find_merge_base(repo, a, b) == b
346
347 def test_true_merge_returns_lca(self, tmp_path: pathlib.Path) -> None:
348 """root→left, root→right: merge_base(left, right) == root."""
349 repo = _make_repo(tmp_path)
350 root = _write_commit(repo, "root")
351 left = _write_commit(repo, "left", parent=root)
352 right = _write_commit(repo, "right", parent=root)
353 assert find_merge_base(repo, left, right) == root
354
355 def test_deeper_lca(self, tmp_path: pathlib.Path) -> None:
356 """A→B→X, A→C→X: merge_base(B, C) == X."""
357 repo = _make_repo(tmp_path)
358 x = _write_commit(repo, "X")
359 b = _write_commit(repo, "B", parent=x)
360 c = _write_commit(repo, "C", parent=x)
361 assert find_merge_base(repo, b, c) == x
362
363 def test_no_common_ancestor_returns_none(self, tmp_path: pathlib.Path) -> None:
364 """Two root commits with no shared history → None."""
365 repo = _make_repo(tmp_path)
366 root_a = _write_commit(repo, "rootA")
367 root_b = _write_commit(repo, "rootB")
368 result = find_merge_base(repo, root_a, root_b)
369 assert result is None
370
371 def test_symmetric(self, tmp_path: pathlib.Path) -> None:
372 """find_merge_base(a, b) == find_merge_base(b, a)."""
373 repo = _make_repo(tmp_path)
374 root = _write_commit(repo, "root")
375 left = _write_commit(repo, "left", parent=root)
376 right = _write_commit(repo, "right", parent=root)
377 assert find_merge_base(repo, left, right) == find_merge_base(repo, right, left)
378
379 def test_max_ancestors_raises_on_deep_graph(self, tmp_path: pathlib.Path) -> None:
380 """When max_ancestors is exceeded, raises ValueError."""
381 repo = _make_repo(tmp_path)
382 # Build a chain of 10 commits.
383 prev = None
384 for i in range(10):
385 prev = _write_commit(repo, f"c{i}", parent=prev)
386 a = _write_commit(repo, "a", parent=prev)
387 b_root = _write_commit(repo, "b_root")
388 b = _write_commit(repo, "b", parent=b_root)
389 # With max_ancestors=1, BFS from a and b won't find common ancestor.
390 with pytest.raises(ValueError, match="max_ancestors"):
391 find_merge_base(repo, a, b, max_ancestors=1)
392
393 def test_ancestor_of_itself_via_chain(self, tmp_path: pathlib.Path) -> None:
394 """merge_base(tip, root) == root when root is an ancestor of tip."""
395 repo = _make_repo(tmp_path)
396 root = _write_commit(repo, "root")
397 mid = _write_commit(repo, "mid", parent=root)
398 tip = _write_commit(repo, "tip", parent=mid)
399 result = find_merge_base(repo, tip, root)
400 assert result == root
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago