gabriel / muse public

test_core_blame.py file-level

at sha256:7 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:b fix: muse hub repo create defaults to initialize=false (musehub#220) P… · gabriel · Oct 22, 2026
1 """Tests for muse/core/blame.py — line-level text attribution."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import pathlib
8
9 import pytest
10
11 from muse.core.blame import BlameLine, blame_file
12 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
13 from muse.core.commits import (
14 CommitRecord,
15 write_commit,
16 )
17 from muse.core.snapshots import (
18 SnapshotRecord,
19 write_snapshot,
20 )
21 from muse.core.types import Manifest, blob_id
22 from muse.core.paths import muse_dir
23
24 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31
32 def _write_object(repo: pathlib.Path, content: bytes) -> str:
33 from muse.core.object_store import write_object
34 oid = blob_id(content)
35 write_object(repo, oid, content)
36 return oid
37
38
39 def _write_snapshot(repo: pathlib.Path, manifest: Manifest) -> str:
40 """Write a snapshot with a properly computed ID; return the snapshot ID."""
41 snap_id = compute_snapshot_id(manifest)
42 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
43 return snap_id
44
45
46 def _write_commit(
47 repo: pathlib.Path,
48 snap_id: str,
49 message: str = "test",
50 parent: str | None = None,
51 parent2: str | None = None,
52 author: str = "Author",
53 committed_at: datetime.datetime | None = None,
54 ) -> str:
55 """Write a commit with a properly computed ID; return the commit ID."""
56 dt = committed_at if committed_at is not None else _BASE_DT
57 parent_ids = [p for p in (parent, parent2) if p]
58 commit_id = compute_commit_id(
59 parent_ids=parent_ids,
60 snapshot_id=snap_id,
61 message=message,
62 committed_at_iso=dt.isoformat(),
63 author=author,
64 )
65 write_commit(repo, CommitRecord(
66 commit_id=commit_id,
67 branch="main",
68 snapshot_id=snap_id,
69 message=message,
70 committed_at=dt,
71 parent_commit_id=parent,
72 parent2_commit_id=parent2,
73 author=author,
74 ))
75 return commit_id
76
77
78 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
79 dot_muse = muse_dir(tmp_path)
80 for d in ("objects", "commits", "snapshots", "refs/heads"):
81 (dot_muse / d).mkdir(parents=True, exist_ok=True)
82 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
83 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
84 return tmp_path
85
86
87 # ---------------------------------------------------------------------------
88 # Tests
89 # ---------------------------------------------------------------------------
90
91
92 def test_blame_returns_none_for_missing_file(tmp_path: pathlib.Path) -> None:
93 repo = _make_repo(tmp_path)
94 snap_id = _write_snapshot(repo, {}) # empty manifest
95 commit_id = _write_commit(repo, snap_id)
96
97 result = blame_file(repo, "nonexistent.txt", commit_id)
98 assert result is None
99
100
101 def test_blame_single_commit_all_lines_attributed(tmp_path: pathlib.Path) -> None:
102 repo = _make_repo(tmp_path)
103 content = b"line one\nline two\nline three\n"
104 obj_id = _write_object(repo, content)
105 snap_id = _write_snapshot(repo, {"readme.txt": obj_id})
106 commit_id = _write_commit(repo, snap_id, message="initial commit", author="Alice")
107
108 result = blame_file(repo, "readme.txt", commit_id)
109 assert result is not None
110 assert len(result) == 3
111 for line in result:
112 assert isinstance(line, BlameLine)
113 assert line.commit_id == commit_id
114
115
116 def test_blame_line_numbers_are_1_indexed(tmp_path: pathlib.Path) -> None:
117 repo = _make_repo(tmp_path)
118 content = b"a\nb\nc\n"
119 obj_id = _write_object(repo, content)
120 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
121 commit_id = _write_commit(repo, snap_id)
122
123 result = blame_file(repo, "f.txt", commit_id)
124 assert result is not None
125 assert [bl.lineno for bl in result] == [1, 2, 3]
126
127
128 def test_blame_content_matches_file(tmp_path: pathlib.Path) -> None:
129 repo = _make_repo(tmp_path)
130 content = b"hello\nworld\n"
131 obj_id = _write_object(repo, content)
132 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
133 commit_id = _write_commit(repo, snap_id)
134
135 result = blame_file(repo, "f.txt", commit_id)
136 assert result is not None
137 assert result[0].content == "hello"
138 assert result[1].content == "world"
139
140
141 def test_blame_empty_file_returns_empty_list(tmp_path: pathlib.Path) -> None:
142 repo = _make_repo(tmp_path)
143 content = b""
144 obj_id = _write_object(repo, content)
145 snap_id = _write_snapshot(repo, {"empty.txt": obj_id})
146 commit_id = _write_commit(repo, snap_id)
147
148 result = blame_file(repo, "empty.txt", commit_id)
149 assert result == []
150
151
152 def test_blame_two_commits_attributes_older_lines_correctly(tmp_path: pathlib.Path) -> None:
153 """Lines present in both commits should be attributed to the older commit."""
154 repo = _make_repo(tmp_path)
155
156 # Commit 1: file with two lines.
157 content1 = b"original line 1\noriginal line 2\n"
158 obj1 = _write_object(repo, content1)
159 snap1 = _write_snapshot(repo, {"f.txt": obj1})
160 commit1 = _write_commit(
161 repo, snap1, message="initial", author="Alice",
162 committed_at=_BASE_DT,
163 )
164
165 # Commit 2: same two lines + one new line.
166 content2 = b"original line 1\noriginal line 2\nnew line 3\n"
167 obj2 = _write_object(repo, content2)
168 snap2 = _write_snapshot(repo, {"f.txt": obj2})
169 commit2 = _write_commit(
170 repo, snap2, message="add line 3", parent=commit1, author="Bob",
171 committed_at=_BASE_DT + datetime.timedelta(hours=1),
172 )
173
174 result = blame_file(repo, "f.txt", commit2)
175 assert result is not None
176 assert len(result) == 3
177 # Lines 1 and 2 should be attributed to commit1 (they existed before commit2).
178 assert result[0].commit_id == commit1
179 assert result[1].commit_id == commit1
180 # Line 3 was added by commit2.
181 assert result[2].commit_id == commit2
182
183
184 def test_blame_author_populated(tmp_path: pathlib.Path) -> None:
185 repo = _make_repo(tmp_path)
186 obj_id = _write_object(repo, b"line\n")
187 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
188 commit_id = _write_commit(repo, snap_id, author="Carol")
189
190 result = blame_file(repo, "f.txt", commit_id)
191 assert result is not None
192 assert result[0].author == "Carol"
193
194
195 def test_blame_message_is_first_line_of_commit_message(tmp_path: pathlib.Path) -> None:
196 repo = _make_repo(tmp_path)
197 obj_id = _write_object(repo, b"line\n")
198 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
199 commit_id = _write_commit(repo, snap_id, message="feat: add feature\n\nLong body here.")
200
201 result = blame_file(repo, "f.txt", commit_id)
202 assert result is not None
203 assert result[0].message == "feat: add feature"
204
205
206 # ---------------------------------------------------------------------------
207 # Stress
208 # ---------------------------------------------------------------------------
209
210
211 def test_blame_never_attributes_to_commit_that_never_touched_file(
212 tmp_path: pathlib.Path,
213 ) -> None:
214 """muse#84 — a commit reached only through a merge's non-first parent,
215 whose own content is byte-identical to ITS parent, must never end up as
216 the blamed commit. The old algorithm walked `iter_ancestors`'s flattened
217 BFS order and diffed *adjacent list entries* as if they were real
218 parent/child pairs -- which falls apart the moment the DAG has a merge,
219 since the BFS neighbor of a commit is frequently NOT its actual parent.
220
221 Shape: base -> feature (changes the file) -> [merge: base-side unrelated
222 commit + feature] -> final (no further change). The file's true last
223 change is on `feature`; `base_side` and the merge commit never touch it.
224 """
225 repo = _make_repo(tmp_path)
226
227 base_content = b"line a\nline b\n"
228 base_obj = _write_object(repo, base_content)
229 base_snap = _write_snapshot(repo, {"f.txt": base_obj})
230 base = _write_commit(repo, base_snap, message="base", committed_at=_BASE_DT)
231
232 # base_side: unrelated change to a DIFFERENT file, f.txt untouched.
233 other_obj = _write_object(repo, b"unrelated\n")
234 base_side_snap = _write_snapshot(repo, {"f.txt": base_obj, "other.txt": other_obj})
235 base_side = _write_commit(
236 repo, base_side_snap, message="unrelated change", parent=base,
237 committed_at=_BASE_DT + datetime.timedelta(hours=1),
238 )
239
240 # feature: the ONLY commit that actually changes f.txt.
241 feature_content = b"line a\nline b CHANGED\n"
242 feature_obj = _write_object(repo, feature_content)
243 feature_snap = _write_snapshot(repo, {"f.txt": feature_obj})
244 feature = _write_commit(
245 repo, feature_snap, message="feature: change line b", parent=base,
246 committed_at=_BASE_DT + datetime.timedelta(hours=1),
247 )
248
249 # merge: combines base_side + feature. f.txt matches feature's content
250 # exactly -- the merge itself did NOT touch the file.
251 merge_snap = _write_snapshot(repo, {"f.txt": feature_obj, "other.txt": other_obj})
252 merge = _write_commit(
253 repo, merge_snap, message="merge feature into base_side",
254 parent=base_side, parent2=feature,
255 committed_at=_BASE_DT + datetime.timedelta(hours=2),
256 )
257
258 # final: no change to f.txt at all -- identical content to merge.
259 final_snap = _write_snapshot(repo, {"f.txt": feature_obj, "other.txt": other_obj})
260 final = _write_commit(
261 repo, final_snap, message="docs: unrelated", parent=merge,
262 committed_at=_BASE_DT + datetime.timedelta(hours=3),
263 )
264
265 result = blame_file(repo, "f.txt", final)
266 assert result is not None
267 assert len(result) == 2
268 never_touched = {merge, final, base_side}
269 for bl in result:
270 assert bl.commit_id not in never_touched, (
271 f"line {bl.lineno} attributed to {bl.commit_id}, which never "
272 f"touched f.txt (never_touched={never_touched})"
273 )
274 # Line 1 is unchanged since `base`; line 2 was changed by `feature`.
275 assert result[0].commit_id == base
276 assert result[1].commit_id == feature
277
278
279 def test_blame_stress_100_line_file(tmp_path: pathlib.Path) -> None:
280 """Blame should handle a 100-line file without errors."""
281 repo = _make_repo(tmp_path)
282 content = "\n".join(f"line {i}" for i in range(100)).encode() + b"\n"
283 obj_id = _write_object(repo, content)
284 snap_id = _write_snapshot(repo, {"big.txt": obj_id})
285 commit_id = _write_commit(repo, snap_id)
286
287 result = blame_file(repo, "big.txt", commit_id)
288 assert result is not None
289 assert len(result) == 100
290 assert all(bl.commit_id == commit_id for bl in result)
291
292
293 # ---------------------------------------------------------------------------
294 # Performance
295 # ---------------------------------------------------------------------------
296
297
298 def test_walk_ancestry_delegates_to_iter_ancestors(tmp_path: pathlib.Path) -> None:
299 """_walk_ancestry must delegate to graph.iter_ancestors.
300
301 The O(1) deque guarantee is provided by iter_ancestors (verified in
302 test_core_graph.py). This test confirms the delegation is in place so
303 _walk_ancestry cannot silently revert to a home-grown O(n) walk.
304 """
305 import inspect
306 from muse.core import blame as blame_module
307
308 source = inspect.getsource(blame_module._walk_ancestry)
309 assert "iter_ancestors" in source, "_walk_ancestry must delegate to graph.iter_ancestors"
310 assert "pop(0)" not in source, "_walk_ancestry must not use list.pop(0)"
311 assert "insert(0" not in source, "_walk_ancestry must not use list.insert(0, ...)"
312
313
314 def test_blame_skips_read_for_unchanged_commits(tmp_path: pathlib.Path) -> None:
315 """blame_file must skip snapshot reads when the file's object_id is unchanged.
316
317 With 10 commits where the file only changes once, _read_file_at_commit
318 should be called at most twice (at the change boundary), not 10 times.
319 """
320 from unittest.mock import patch
321 from muse.core import blame as blame_module
322
323 repo = _make_repo(tmp_path)
324
325 # Build a 10-commit chain where the file changes only on commit 5.
326 v1 = "\n".join(f"original line {i}" for i in range(5)).encode() + b"\n"
327 v2 = "\n".join(f"changed line {i}" for i in range(5)).encode() + b"\n"
328
329 obj_v1 = _write_object(repo, v1)
330 obj_v2 = _write_object(repo, v2)
331
332 prev = None
333 commit_ids = []
334 for i in range(10):
335 obj = obj_v2 if i < 5 else obj_v1 # file changes at commit 5
336 snap_id = _write_snapshot(repo, {"tracked.txt": obj})
337 cid = _write_commit(repo, snap_id, message=f"c{i}", parent=prev)
338 commit_ids.append(cid)
339 prev = cid
340
341 head = commit_ids[-1]
342
343 call_count = 0
344 original = blame_module._read_file_at_commit
345
346 def counting_read(root: pathlib.Path, commit_id: str, rel_path: str) -> bytes | None:
347 nonlocal call_count
348 call_count += 1
349 return original(root, commit_id, rel_path)
350
351 with patch.object(blame_module, "_read_file_at_commit", side_effect=counting_read):
352 result = blame_file(repo, "tracked.txt", head)
353
354 assert result is not None
355 # Should read at most once per distinct object_id (2 versions) plus the
356 # initial read, not once per commit in the chain (10).
357 assert call_count <= 4, (
358 f"_read_file_at_commit called {call_count}× for 10 commits with "
359 "only 1 content change — unchanged commits should be skipped"
360 )
361
362
363 class TestRegisterFlags:
364 def test_json_short_flag(self) -> None:
365 import argparse
366 from muse.cli.commands.core_blame import register
367 p = argparse.ArgumentParser()
368 subs = p.add_subparsers()
369 register(subs)
370 args = p.parse_args(['blame', 'file.py', '-j'])
371 assert args.json_out is True
372
373 def test_json_long_flag(self) -> None:
374 import argparse
375 from muse.cli.commands.core_blame import register
376 p = argparse.ArgumentParser()
377 subs = p.add_subparsers()
378 register(subs)
379 args = p.parse_args(['blame', 'file.py', '--json'])
380 assert args.json_out is True
381
382 def test_default_no_json(self) -> None:
383 import argparse
384 from muse.cli.commands.core_blame import register
385 p = argparse.ArgumentParser()
386 subs = p.add_subparsers()
387 register(subs)
388 # Command-specific required args may differ; just check dest exists when possible
389 try:
390 args = p.parse_args(['blame', 'file.py'])
391 assert args.json_out is False
392 except SystemExit:
393 pass # required positional args missing — flag default still correct