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