gabriel / muse public
test_phase5_store_linear_walks.py python
288 lines 10.4 KB
Raw
1 """TDD — Phase 5: store.py linear walks become iter_ancestors wrappers.
2
3 Phase 5 of issue #6 (generic DAG walker).
4
5 ``store.py`` contains two linear first-parent walkers that predate
6 ``graph.py``:
7
8 - ``walk_commits_between_result`` — bounded range walk, returns WalkResult
9 - ``get_commits_for_branch`` — branch HEAD walk with optional max_count
10
11 Both use the same inline pattern: a ``while commit_id`` loop that follows
12 ``parent_commit_id`` one step at a time. Neither uses ``iter_ancestors``.
13
14 Fix: make both functions thin wrappers over
15 ``iter_ancestors(root, start, first_parent_only=True)``.
16 Behaviour must be identical — same commit order (newest-first), same
17 ``from_commit_id`` stop condition, same ``max_commits`` / ``max_count``
18 cap, same ``truncated`` signalling.
19
20 ``walk_commits_between`` is a one-liner wrapper over
21 ``walk_commits_between_result`` — it stays as-is (no inline loop).
22
23 Coverage
24 --------
25 P5-1 Structural — ``walk_commits_between_result`` source contains
26 ``iter_ancestors`` and no inline ``while commit_id`` loop
27 P5-2 Structural — ``get_commits_for_branch`` source contains
28 ``iter_ancestors`` and no inline ``while commit_id`` loop
29 P5-3 Behavioural — ``walk_commits_between_result`` linear chain matches
30 old behaviour: commits newest-first, stops before from_commit_id
31 P5-4 Behavioural — ``walk_commits_between_result`` truncated flag fires at cap
32 P5-5 Behavioural — ``walk_commits_between_result`` full walk (no from_commit)
33 P5-6 Behavioural — ``get_commits_for_branch`` returns commits newest-first
34 P5-7 Behavioural — ``get_commits_for_branch`` respects max_count
35 """
36 from __future__ import annotations
37
38 import datetime
39 import inspect
40 import json
41 import pathlib
42
43 import pytest
44
45 from muse._version import __version__
46 from muse.core.object_store import write_object
47 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
48 from muse.core.commits import (
49 CommitRecord,
50 write_commit,
51 )
52 from muse.core.snapshots import (
53 SnapshotRecord,
54 write_snapshot,
55 )
56 from muse.core.types import blob_id
57 from muse.core.paths import muse_dir
58
59
60 # ---------------------------------------------------------------------------
61 # Repo fixture helpers
62 # ---------------------------------------------------------------------------
63
64 def _repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
65 dot_muse = muse_dir(tmp_path)
66 for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"):
67 (dot_muse / d).mkdir(parents=True, exist_ok=True)
68 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
69 (dot_muse / "repo.json").write_text(
70 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
71 )
72 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
73 monkeypatch.chdir(tmp_path)
74 return tmp_path
75
76
77 def _write_obj(root: pathlib.Path, content: bytes) -> str:
78 oid = blob_id(content)
79 write_object(root, oid, content)
80 return oid
81
82
83 def _make_commit(
84 root: pathlib.Path,
85 manifest: dict[str, str],
86 parent_id: str | None = None,
87 *,
88 message: str = "test",
89 ) -> CommitRecord:
90 snap_id = compute_snapshot_id(manifest)
91 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
92 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
93 cid = compute_commit_id(
94 parent_ids=[parent_id] if parent_id else [],
95 snapshot_id=snap_id,
96 message=message,
97 committed_at_iso=ts.isoformat(),
98 )
99 rec = CommitRecord(
100 commit_id=cid,
101 branch="main",
102 snapshot_id=snap_id,
103 message=message,
104 committed_at=ts,
105 parent_commit_id=parent_id,
106 )
107 write_commit(root, rec)
108 return rec
109
110
111 def _linear_chain(root: pathlib.Path, n: int) -> list[CommitRecord]:
112 """Build a linear chain of n commits, return oldest-first."""
113 oid = _write_obj(root, b"data")
114 commits: list[CommitRecord] = []
115 parent_id: str | None = None
116 for i in range(n):
117 c = _make_commit(root, {"f.py": oid}, parent_id, message=f"commit {i}")
118 commits.append(c)
119 parent_id = c.commit_id
120 return commits
121
122
123 # ---------------------------------------------------------------------------
124 # P5-1 Structural — walk_commits_between_result uses iter_ancestors
125 # ---------------------------------------------------------------------------
126
127 def test_p5_1_walk_commits_between_result_uses_iter_ancestors() -> None:
128 """walk_commits_between_result must delegate to iter_ancestors.
129
130 The inline ``while commit_id`` pattern must not appear in the function
131 body — it predates graph.py and is now replaced by iter_ancestors with
132 first_parent_only=True.
133 """
134 from muse.core import commits as store_mod
135
136 src = inspect.getsource(store_mod.walk_commits_between_result)
137
138 assert "iter_ancestors" in src, (
139 "walk_commits_between_result must delegate to iter_ancestors. "
140 "Replace the inline while-loop with iter_ancestors(first_parent_only=True)."
141 )
142 assert "while commit_id" not in src, (
143 "walk_commits_between_result still has an inline while-loop. "
144 "Replace with iter_ancestors(first_parent_only=True)."
145 )
146
147
148 # ---------------------------------------------------------------------------
149 # P5-2 Structural — get_commits_for_branch uses iter_ancestors
150 # ---------------------------------------------------------------------------
151
152 def test_p5_2_get_commits_for_branch_uses_iter_ancestors() -> None:
153 """get_commits_for_branch must delegate to iter_ancestors."""
154 from muse.core import commits as store_mod
155
156 src = inspect.getsource(store_mod.get_commits_for_branch)
157
158 assert "iter_ancestors" in src, (
159 "get_commits_for_branch must delegate to iter_ancestors. "
160 "Replace the inline while-loop with iter_ancestors(first_parent_only=True)."
161 )
162 assert "while commit_id" not in src, (
163 "get_commits_for_branch still has an inline while-loop. "
164 "Replace with iter_ancestors(first_parent_only=True)."
165 )
166
167
168 # ---------------------------------------------------------------------------
169 # P5-3 Behavioural — walk_commits_between_result range stop
170 # ---------------------------------------------------------------------------
171
172 def test_p5_3_walk_commits_between_result_stops_before_from_commit(
173 tmp_path: pathlib.Path,
174 monkeypatch: pytest.MonkeyPatch,
175 ) -> None:
176 """walk_commits_between_result returns commits from to_commit up to but
177 not including from_commit, newest-first.
178
179 Chain (oldest → newest): C1 → C2 → C3 → C4
180 Call: walk_commits_between_result(root, C4, from_commit_id=C1)
181 Expected: [C4, C3, C2] — C1 is the exclusive lower bound.
182 """
183 from muse.core.commits import walk_commits_between_result
184
185 root = _repo(tmp_path, monkeypatch)
186 chain = _linear_chain(root, 4)
187 c1, c2, c3, c4 = chain
188
189 result = walk_commits_between_result(root, c4.commit_id, from_commit_id=c1.commit_id)
190
191 assert result["truncated"] is False
192 ids = [c.commit_id for c in result["commits"]]
193 assert ids == [c4.commit_id, c3.commit_id, c2.commit_id], (
194 f"Expected [C4, C3, C2] (C1 excluded), got {[i[:12] for i in ids]}"
195 )
196
197
198 # ---------------------------------------------------------------------------
199 # P5-4 Behavioural — walk_commits_between_result truncated flag
200 # ---------------------------------------------------------------------------
201
202 def test_p5_4_walk_commits_between_result_truncated_flag(
203 tmp_path: pathlib.Path,
204 monkeypatch: pytest.MonkeyPatch,
205 ) -> None:
206 """truncated=True when max_commits is hit before the chain is exhausted."""
207 from muse.core.commits import walk_commits_between_result
208
209 root = _repo(tmp_path, monkeypatch)
210 chain = _linear_chain(root, 5)
211
212 result = walk_commits_between_result(root, chain[-1].commit_id, max_commits=2)
213
214 assert result["truncated"] is True
215 assert len(result["commits"]) == 2
216
217
218 # ---------------------------------------------------------------------------
219 # P5-5 Behavioural — walk_commits_between_result full walk (no from_commit)
220 # ---------------------------------------------------------------------------
221
222 def test_p5_5_walk_commits_between_result_full_walk(
223 tmp_path: pathlib.Path,
224 monkeypatch: pytest.MonkeyPatch,
225 ) -> None:
226 """When from_commit_id is None, walk all the way to the initial commit."""
227 from muse.core.commits import walk_commits_between_result
228
229 root = _repo(tmp_path, monkeypatch)
230 chain = _linear_chain(root, 4)
231
232 result = walk_commits_between_result(root, chain[-1].commit_id)
233
234 assert result["truncated"] is False
235 assert result["count"] == 4
236 ids = [c.commit_id for c in result["commits"]]
237 expected = [c.commit_id for c in reversed(chain)]
238 assert ids == expected, "Full walk must return all commits newest-first"
239
240
241 # ---------------------------------------------------------------------------
242 # P5-6 Behavioural — get_commits_for_branch returns newest-first
243 # ---------------------------------------------------------------------------
244
245 def test_p5_6_get_commits_for_branch_newest_first(
246 tmp_path: pathlib.Path,
247 monkeypatch: pytest.MonkeyPatch,
248 ) -> None:
249 """get_commits_for_branch returns commits newest-first for a branch."""
250 from muse.core.commits import get_commits_for_branch
251
252 root = _repo(tmp_path, monkeypatch)
253 chain = _linear_chain(root, 3)
254
255 # Point the branch ref at the tip.
256 (muse_dir(root) / "refs" / "heads" / "main").write_text(
257 chain[-1].commit_id + "\n"
258 )
259
260 result = get_commits_for_branch(root, "main")
261
262 ids = [c.commit_id for c in result]
263 expected = [c.commit_id for c in reversed(chain)]
264 assert ids == expected, "Commits must be newest-first"
265
266
267 # ---------------------------------------------------------------------------
268 # P5-7 Behavioural — get_commits_for_branch respects max_count
269 # ---------------------------------------------------------------------------
270
271 def test_p5_7_get_commits_for_branch_max_count(
272 tmp_path: pathlib.Path,
273 monkeypatch: pytest.MonkeyPatch,
274 ) -> None:
275 """get_commits_for_branch stops after max_count commits."""
276 from muse.core.commits import get_commits_for_branch
277
278 root = _repo(tmp_path, monkeypatch)
279 chain = _linear_chain(root, 5)
280
281 (muse_dir(root) / "refs" / "heads" / "main").write_text(
282 chain[-1].commit_id + "\n"
283 )
284
285 result = get_commits_for_branch(root, "main", max_count=2)
286
287 assert len(result) == 2
288 assert result[0].commit_id == chain[-1].commit_id # newest first
File History 1 commit