gabriel / muse public
test_plumbing_commit_graph.py python
399 lines 14.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive tests for ``muse plumbing commit-graph``.
2
3 Coverage tiers
4 --------------
5 - Unit: _CommitNode schema, _DEFAULT_MAX
6 - Integration: linear chain, --tip, --max, --count, --first-parent, --stop-at,
7 --ancestry-path, text format, json shorthand
8 - Security: errors to stderr, no traceback on bad tip
9 - Stress: 50-commit chain traversal
10 """
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16
17 from muse.core.errors import ExitCode
18 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
19 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
20 from tests.cli_test_helper import CliRunner, InvokeResult
21
22 runner = CliRunner()
23
24 _DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
32 repo = tmp_path / "repo"
33 muse = repo / ".muse"
34 for sub in ("objects", "commits", "snapshots", "refs/heads"):
35 (muse / sub).mkdir(parents=True)
36 (muse / "HEAD").write_text("ref: refs/heads/main")
37 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
38 return repo
39
40
41 def _snap(repo: pathlib.Path) -> str:
42 """Write a snapshot with an empty manifest; return its content-addressed ID."""
43 sid = compute_snapshot_id({})
44 write_snapshot(repo, SnapshotRecord(
45 snapshot_id=sid,
46 manifest={},
47 created_at=_DT,
48 ))
49 return sid
50
51
52 def _commit(
53 repo: pathlib.Path,
54 snap_id: str,
55 *,
56 parent: str | None = None,
57 parent2: str | None = None,
58 message: str = "test",
59 ) -> str:
60 """Write a commit with a real content-addressed ID; return the commit ID."""
61 parent_ids = [p for p in [parent, parent2] if p is not None]
62 commit_id = compute_commit_id(parent_ids, snap_id, message, _DT.isoformat())
63 write_commit(repo, CommitRecord(
64 commit_id=commit_id,
65 repo_id="test-repo",
66 branch="main",
67 snapshot_id=snap_id,
68 message=message,
69 committed_at=_DT,
70 parent_commit_id=parent,
71 parent2_commit_id=parent2,
72 ))
73 return commit_id
74
75
76 def _set_head(repo: pathlib.Path, branch: str, commit_id: str) -> None:
77 ref = repo / ".muse" / "refs" / "heads" / branch
78 ref.parent.mkdir(parents=True, exist_ok=True)
79 ref.write_text(commit_id)
80 (repo / ".muse" / "HEAD").write_text(f"ref: refs/heads/{branch}")
81
82
83 def _cg(repo: pathlib.Path, *args: str) -> InvokeResult:
84 from muse.cli.app import main as cli
85 return runner.invoke(
86 cli,
87 ["commit-graph", *args],
88 env={"MUSE_REPO_ROOT": str(repo)},
89 )
90
91
92 # ---------------------------------------------------------------------------
93 # Unit
94 # ---------------------------------------------------------------------------
95
96
97 class TestUnit:
98 def test_commit_node_fields(self) -> None:
99 from muse.cli.commands.plumbing.commit_graph import _CommitNode
100 fields = set(_CommitNode.__annotations__.keys())
101 assert "commit_id" in fields
102 assert "parent_commit_id" in fields
103 assert "parent2_commit_id" in fields
104 assert "message" in fields
105 assert "snapshot_id" in fields
106 assert "branch" in fields
107 assert "committed_at" in fields
108 assert "author" in fields
109
110 def test_default_max(self) -> None:
111 from muse.cli.commands.plumbing.commit_graph import _DEFAULT_MAX
112 assert _DEFAULT_MAX >= 1000
113
114 def test_ancestors_of_single_commit(self, tmp_path: pathlib.Path) -> None:
115 from muse.cli.commands.plumbing.commit_graph import _ancestors_of
116 repo = _make_repo(tmp_path)
117 snap_id = _snap(repo)
118 cid = _commit(repo, snap_id)
119 result = _ancestors_of(repo, cid)
120 assert cid in result
121
122 def test_ancestors_of_linear_chain(self, tmp_path: pathlib.Path) -> None:
123 from muse.cli.commands.plumbing.commit_graph import _ancestors_of
124 repo = _make_repo(tmp_path)
125 snap_id = _snap(repo)
126 c1 = _commit(repo, snap_id, message="c1")
127 c2 = _commit(repo, snap_id, parent=c1, message="c2")
128 c3 = _commit(repo, snap_id, parent=c2, message="c3")
129 result = _ancestors_of(repo, c3)
130 assert c1 in result
131 assert c2 in result
132 assert c3 in result
133
134 def test_ancestors_of_merge_commit_follows_both_parents(self, tmp_path: pathlib.Path) -> None:
135 from muse.cli.commands.plumbing.commit_graph import _ancestors_of
136 repo = _make_repo(tmp_path)
137 snap_id = _snap(repo)
138 base = _commit(repo, snap_id, message="base")
139 left = _commit(repo, snap_id, parent=base, message="left")
140 right = _commit(repo, snap_id, parent=base, message="right")
141 merge = _commit(repo, snap_id, parent=left, parent2=right, message="merge")
142 result = _ancestors_of(repo, merge)
143 assert base in result
144 assert left in result
145 assert right in result
146 assert merge in result
147
148 def test_ancestors_of_missing_commit_returns_partial(self, tmp_path: pathlib.Path) -> None:
149 from muse.cli.commands.plumbing.commit_graph import _ancestors_of
150 repo = _make_repo(tmp_path)
151 missing = "ab" + "0" * 62
152 # Missing commit: returns set containing just the start ID that was visited
153 result = _ancestors_of(repo, missing)
154 # BFS: visits the missing cid, can't find record, stops
155 assert missing in result
156
157
158 # ---------------------------------------------------------------------------
159 # Integration — JSON format
160 # ---------------------------------------------------------------------------
161
162
163 class TestJsonFormat:
164 def test_linear_two_commits(self, tmp_path: pathlib.Path) -> None:
165 repo = _make_repo(tmp_path)
166 snap_id = _snap(repo)
167 c1 = _commit(repo, snap_id, message="c1")
168 c2 = _commit(repo, snap_id, parent=c1, message="c2")
169 _set_head(repo, "main", c2)
170 result = _cg(repo)
171 assert result.exit_code == 0
172 data = json.loads(result.output)
173 assert data["count"] == 2
174 ids = {c["commit_id"] for c in data["commits"]}
175 assert {c1, c2} == ids
176
177 def test_tip_is_present_in_output(self, tmp_path: pathlib.Path) -> None:
178 repo = _make_repo(tmp_path)
179 snap_id = _snap(repo)
180 cid = _commit(repo, snap_id)
181 _set_head(repo, "main", cid)
182 data = json.loads(_cg(repo).output)
183 assert data["tip"] == cid
184
185 def test_explicit_tip(self, tmp_path: pathlib.Path) -> None:
186 repo = _make_repo(tmp_path)
187 snap_id = _snap(repo)
188 cid = _commit(repo, snap_id)
189 result = _cg(repo, "--tip", cid)
190 assert result.exit_code == 0
191 data = json.loads(result.output)
192 assert data["tip"] == cid
193
194 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
195 repo = _make_repo(tmp_path)
196 snap_id = _snap(repo)
197 cid = _commit(repo, snap_id)
198 _set_head(repo, "main", cid)
199 result = _cg(repo, "--json")
200 assert result.exit_code == 0
201 assert "commits" in json.loads(result.output)
202
203 def test_truncated_flag_when_limited(self, tmp_path: pathlib.Path) -> None:
204 repo = _make_repo(tmp_path)
205 snap_id = _snap(repo)
206 c1 = _commit(repo, snap_id, message="c1")
207 c2 = _commit(repo, snap_id, parent=c1, message="c2")
208 _set_head(repo, "main", c2)
209 data = json.loads(_cg(repo, "--max", "1").output)
210 assert data["truncated"] is True
211
212
213 # ---------------------------------------------------------------------------
214 # Integration — --count
215 # ---------------------------------------------------------------------------
216
217
218 class TestCountOnly:
219 def test_count_returns_integer(self, tmp_path: pathlib.Path) -> None:
220 repo = _make_repo(tmp_path)
221 snap_id = _snap(repo)
222 cid = _commit(repo, snap_id)
223 _set_head(repo, "main", cid)
224 data = json.loads(_cg(repo, "--count").output)
225 assert data["count"] == 1
226 assert "commits" not in data
227
228 def test_count_reflects_chain_length(self, tmp_path: pathlib.Path) -> None:
229 repo = _make_repo(tmp_path)
230 snap_id = _snap(repo)
231 c1 = _commit(repo, snap_id, message="c1")
232 c2 = _commit(repo, snap_id, parent=c1, message="c2")
233 c3 = _commit(repo, snap_id, parent=c2, message="c3")
234 _set_head(repo, "main", c3)
235 data = json.loads(_cg(repo, "--count").output)
236 assert data["count"] == 3
237
238
239 # ---------------------------------------------------------------------------
240 # Integration — --first-parent
241 # ---------------------------------------------------------------------------
242
243
244 class TestFirstParent:
245 def test_first_parent_skips_merge_parent(self, tmp_path: pathlib.Path) -> None:
246 repo = _make_repo(tmp_path)
247 snap_id = _snap(repo)
248 p1 = _commit(repo, snap_id, message="p1")
249 p2 = _commit(repo, snap_id, message="p2")
250 merge = _commit(repo, snap_id, parent=p1, parent2=p2, message="merge")
251 _set_head(repo, "main", merge)
252 data = json.loads(_cg(repo, "--first-parent").output)
253 ids = {c["commit_id"] for c in data["commits"]}
254 assert p2 not in ids
255 assert p1 in ids
256 assert merge in ids
257
258
259 # ---------------------------------------------------------------------------
260 # Integration — --stop-at
261 # ---------------------------------------------------------------------------
262
263
264 class TestStopAt:
265 def test_stop_at_excludes_old_commits(self, tmp_path: pathlib.Path) -> None:
266 repo = _make_repo(tmp_path)
267 snap_id = _snap(repo)
268 c1 = _commit(repo, snap_id, message="c1")
269 c2 = _commit(repo, snap_id, parent=c1, message="c2")
270 c3 = _commit(repo, snap_id, parent=c2, message="c3")
271 _set_head(repo, "main", c3)
272 data = json.loads(_cg(repo, "--stop-at", c2).output)
273 ids = {c["commit_id"] for c in data["commits"]}
274 assert c2 not in ids
275 assert c1 not in ids
276 assert c3 in ids
277
278
279 # ---------------------------------------------------------------------------
280 # Integration — text format
281 # ---------------------------------------------------------------------------
282
283
284 class TestTextFormat:
285 def test_text_one_id_per_line(self, tmp_path: pathlib.Path) -> None:
286 repo = _make_repo(tmp_path)
287 snap_id = _snap(repo)
288 cid = _commit(repo, snap_id)
289 _set_head(repo, "main", cid)
290 result = _cg(repo, "--format", "text")
291 assert result.exit_code == 0
292 assert cid in result.output
293
294
295 # ---------------------------------------------------------------------------
296 # Error cases
297 # ---------------------------------------------------------------------------
298
299
300 class TestErrors:
301 def test_no_commits_errors(self, tmp_path: pathlib.Path) -> None:
302 repo = _make_repo(tmp_path)
303 result = _cg(repo)
304 assert result.exit_code == ExitCode.USER_ERROR
305
306 def test_tip_not_found_errors(self, tmp_path: pathlib.Path) -> None:
307 repo = _make_repo(tmp_path)
308 result = _cg(repo, "--tip", "dead" + "beef" * 15)
309 assert result.exit_code == ExitCode.USER_ERROR
310
311 def test_ancestry_path_without_stop_at_errors(self, tmp_path: pathlib.Path) -> None:
312 repo = _make_repo(tmp_path)
313 snap_id = _snap(repo)
314 cid = _commit(repo, snap_id)
315 _set_head(repo, "main", cid)
316 result = _cg(repo, "--ancestry-path")
317 assert result.exit_code == ExitCode.USER_ERROR
318
319 def test_no_traceback_on_bad_tip(self, tmp_path: pathlib.Path) -> None:
320 repo = _make_repo(tmp_path)
321 result = _cg(repo, "--tip", "bad")
322 assert "Traceback" not in result.output
323
324
325 # ---------------------------------------------------------------------------
326 # Stress
327 # ---------------------------------------------------------------------------
328
329
330 class TestSecurity:
331 def test_format_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
332 repo = _make_repo(tmp_path)
333 r = _cg(repo, "--format", "xml")
334 assert r.exit_code != 0
335 assert r.stdout_bytes == b""
336 assert "error" in r.stderr.lower()
337
338 def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None:
339 repo = _make_repo(tmp_path)
340 r = _cg(repo, "--format", "bad")
341 assert "Traceback" not in r.output
342 assert "Traceback" not in r.stderr
343
344 def test_ansi_in_tip_rejected_gracefully(self, tmp_path: pathlib.Path) -> None:
345 """An ANSI-injected tip ID must not crash; it's not a valid commit."""
346 repo = _make_repo(tmp_path)
347 r = _cg(repo, "--tip", "\x1b[31mbad\x1b[0m")
348 assert "Traceback" not in r.output
349 assert "Traceback" not in r.stderr
350
351 def test_json_shorthand_flag(self, tmp_path: pathlib.Path) -> None:
352 repo = _make_repo(tmp_path)
353 snap_id = _snap(repo)
354 cid = _commit(repo, snap_id)
355 _set_head(repo, "main", cid)
356 r = _cg(repo, "--json")
357 assert r.exit_code == 0
358 d = json.loads(r.output)
359 assert "commits" in d
360
361
362 class TestStress:
363 def test_50_commit_linear_chain(self, tmp_path: pathlib.Path) -> None:
364 repo = _make_repo(tmp_path)
365 snap_id = _snap(repo)
366 prev: str | None = None
367 for i in range(50):
368 prev = _commit(repo, snap_id, parent=prev, message=f"commit {i}")
369 assert prev is not None
370 _set_head(repo, "main", prev)
371 data = json.loads(_cg(repo, "--count").output)
372 assert data["count"] == 50
373
374 def test_branching_dag_100_commits(self, tmp_path: pathlib.Path) -> None:
375 """10-branch DAG — --ancestry-path + --first-parent should complete."""
376 repo = _make_repo(tmp_path)
377 snap_id = _snap(repo)
378 base = _commit(repo, snap_id, message="base")
379 tips: list[str] = []
380 for i in range(10):
381 tip = _commit(repo, snap_id, parent=base, message=f"branch {i}")
382 tips.append(tip)
383 merge = _commit(repo, snap_id, parent=tips[-1], parent2=tips[-2], message="merge")
384 _set_head(repo, "main", merge)
385 r = _cg(repo, "--json")
386 assert r.exit_code == 0
387 d = json.loads(r.output)
388 commit_ids = {c["commit_id"] for c in d["commits"]}
389 assert merge in commit_ids
390 assert base in commit_ids
391
392 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None:
393 repo = _make_repo(tmp_path)
394 snap_id = _snap(repo)
395 cid = _commit(repo, snap_id)
396 _set_head(repo, "main", cid)
397 for i in range(200):
398 r = _cg(repo)
399 assert r.exit_code == 0, f"failed at {i}"
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago