gabriel / muse public
test_core_blame.py python
317 lines 11.1 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/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.snapshot import compute_commit_id, compute_snapshot_id
13 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
14 from muse.core._types import Manifest, blob_id
15
16 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
17
18
19 # ---------------------------------------------------------------------------
20 # Helpers
21 # ---------------------------------------------------------------------------
22
23
24 def _write_object(repo: pathlib.Path, content: bytes) -> str:
25 from muse.core.object_store import write_object
26 oid = blob_id(content)
27 write_object(repo, oid, content)
28 return oid
29
30
31 def _write_snapshot(repo: pathlib.Path, manifest: Manifest) -> str:
32 """Write a snapshot with a properly computed ID; return the snapshot ID."""
33 snap_id = compute_snapshot_id(manifest)
34 write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
35 return snap_id
36
37
38 def _write_commit(
39 repo: pathlib.Path,
40 snap_id: str,
41 message: str = "test",
42 parent: str | None = None,
43 author: str = "Author",
44 committed_at: datetime.datetime | None = None,
45 ) -> str:
46 """Write a commit with a properly computed ID; return the commit ID."""
47 dt = committed_at if committed_at is not None else _BASE_DT
48 parent_ids = [parent] if parent else []
49 commit_id = compute_commit_id(
50 repo_id="test-repo",
51 parent_ids=parent_ids,
52 snapshot_id=snap_id,
53 message=message,
54 committed_at_iso=dt.isoformat(),
55 author=author,
56 )
57 write_commit(repo, CommitRecord(
58 commit_id=commit_id,
59 repo_id="test-repo",
60 created_on_branch="main",
61 snapshot_id=snap_id,
62 message=message,
63 committed_at=dt,
64 parent_commit_id=parent,
65 author=author,
66 ))
67 return commit_id
68
69
70 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
71 muse = tmp_path / ".muse"
72 for d in ("objects", "commits", "snapshots", "refs/heads"):
73 (muse / d).mkdir(parents=True, exist_ok=True)
74 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
75 (muse / "HEAD").write_text("ref: refs/heads/main\n")
76 return tmp_path
77
78
79 # ---------------------------------------------------------------------------
80 # Tests
81 # ---------------------------------------------------------------------------
82
83
84 def test_blame_returns_none_for_missing_file(tmp_path: pathlib.Path) -> None:
85 repo = _make_repo(tmp_path)
86 snap_id = _write_snapshot(repo, {}) # empty manifest
87 commit_id = _write_commit(repo, snap_id)
88
89 result = blame_file(repo, "nonexistent.txt", commit_id)
90 assert result is None
91
92
93 def test_blame_single_commit_all_lines_attributed(tmp_path: pathlib.Path) -> None:
94 repo = _make_repo(tmp_path)
95 content = b"line one\nline two\nline three\n"
96 obj_id = _write_object(repo, content)
97 snap_id = _write_snapshot(repo, {"readme.txt": obj_id})
98 commit_id = _write_commit(repo, snap_id, message="initial commit", author="Alice")
99
100 result = blame_file(repo, "readme.txt", commit_id)
101 assert result is not None
102 assert len(result) == 3
103 for line in result:
104 assert isinstance(line, BlameLine)
105 assert line.commit_id == commit_id
106
107
108 def test_blame_line_numbers_are_1_indexed(tmp_path: pathlib.Path) -> None:
109 repo = _make_repo(tmp_path)
110 content = b"a\nb\nc\n"
111 obj_id = _write_object(repo, content)
112 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
113 commit_id = _write_commit(repo, snap_id)
114
115 result = blame_file(repo, "f.txt", commit_id)
116 assert result is not None
117 assert [bl.lineno for bl in result] == [1, 2, 3]
118
119
120 def test_blame_content_matches_file(tmp_path: pathlib.Path) -> None:
121 repo = _make_repo(tmp_path)
122 content = b"hello\nworld\n"
123 obj_id = _write_object(repo, content)
124 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
125 commit_id = _write_commit(repo, snap_id)
126
127 result = blame_file(repo, "f.txt", commit_id)
128 assert result is not None
129 assert result[0].content == "hello"
130 assert result[1].content == "world"
131
132
133 def test_blame_empty_file_returns_empty_list(tmp_path: pathlib.Path) -> None:
134 repo = _make_repo(tmp_path)
135 content = b""
136 obj_id = _write_object(repo, content)
137 snap_id = _write_snapshot(repo, {"empty.txt": obj_id})
138 commit_id = _write_commit(repo, snap_id)
139
140 result = blame_file(repo, "empty.txt", commit_id)
141 assert result == []
142
143
144 def test_blame_two_commits_attributes_older_lines_correctly(tmp_path: pathlib.Path) -> None:
145 """Lines present in both commits should be attributed to the older commit."""
146 repo = _make_repo(tmp_path)
147
148 # Commit 1: file with two lines.
149 content1 = b"original line 1\noriginal line 2\n"
150 obj1 = _write_object(repo, content1)
151 snap1 = _write_snapshot(repo, {"f.txt": obj1})
152 commit1 = _write_commit(
153 repo, snap1, message="initial", author="Alice",
154 committed_at=_BASE_DT,
155 )
156
157 # Commit 2: same two lines + one new line.
158 content2 = b"original line 1\noriginal line 2\nnew line 3\n"
159 obj2 = _write_object(repo, content2)
160 snap2 = _write_snapshot(repo, {"f.txt": obj2})
161 commit2 = _write_commit(
162 repo, snap2, message="add line 3", parent=commit1, author="Bob",
163 committed_at=_BASE_DT + datetime.timedelta(hours=1),
164 )
165
166 result = blame_file(repo, "f.txt", commit2)
167 assert result is not None
168 assert len(result) == 3
169 # Lines 1 and 2 should be attributed to commit1 (they existed before commit2).
170 assert result[0].commit_id == commit1
171 assert result[1].commit_id == commit1
172 # Line 3 was added by commit2.
173 assert result[2].commit_id == commit2
174
175
176 def test_blame_author_populated(tmp_path: pathlib.Path) -> None:
177 repo = _make_repo(tmp_path)
178 obj_id = _write_object(repo, b"line\n")
179 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
180 commit_id = _write_commit(repo, snap_id, author="Carol")
181
182 result = blame_file(repo, "f.txt", commit_id)
183 assert result is not None
184 assert result[0].author == "Carol"
185
186
187 def test_blame_message_is_first_line_of_commit_message(tmp_path: pathlib.Path) -> None:
188 repo = _make_repo(tmp_path)
189 obj_id = _write_object(repo, b"line\n")
190 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
191 commit_id = _write_commit(repo, snap_id, message="feat: add feature\n\nLong body here.")
192
193 result = blame_file(repo, "f.txt", commit_id)
194 assert result is not None
195 assert result[0].message == "feat: add feature"
196
197
198 # ---------------------------------------------------------------------------
199 # Stress
200 # ---------------------------------------------------------------------------
201
202
203 def test_blame_stress_100_line_file(tmp_path: pathlib.Path) -> None:
204 """Blame should handle a 100-line file without errors."""
205 repo = _make_repo(tmp_path)
206 content = "\n".join(f"line {i}" for i in range(100)).encode() + b"\n"
207 obj_id = _write_object(repo, content)
208 snap_id = _write_snapshot(repo, {"big.txt": obj_id})
209 commit_id = _write_commit(repo, snap_id)
210
211 result = blame_file(repo, "big.txt", commit_id)
212 assert result is not None
213 assert len(result) == 100
214 assert all(bl.commit_id == commit_id for bl in result)
215
216
217 # ---------------------------------------------------------------------------
218 # Performance
219 # ---------------------------------------------------------------------------
220
221
222 def test_walk_ancestry_delegates_to_iter_ancestors(tmp_path: pathlib.Path) -> None:
223 """_walk_ancestry must delegate to graph.iter_ancestors.
224
225 The O(1) deque guarantee is provided by iter_ancestors (verified in
226 test_core_graph.py). This test confirms the delegation is in place so
227 _walk_ancestry cannot silently revert to a home-grown O(n) walk.
228 """
229 import inspect
230 from muse.core import blame as blame_module
231
232 source = inspect.getsource(blame_module._walk_ancestry)
233 assert "iter_ancestors" in source, "_walk_ancestry must delegate to graph.iter_ancestors"
234 assert "pop(0)" not in source, "_walk_ancestry must not use list.pop(0)"
235 assert "insert(0" not in source, "_walk_ancestry must not use list.insert(0, ...)"
236
237
238 def test_blame_skips_read_for_unchanged_commits(tmp_path: pathlib.Path) -> None:
239 """blame_file must skip snapshot reads when the file's object_id is unchanged.
240
241 With 10 commits where the file only changes once, _read_file_at_commit
242 should be called at most twice (at the change boundary), not 10 times.
243 """
244 from unittest.mock import patch
245 from muse.core import blame as blame_module
246
247 repo = _make_repo(tmp_path)
248
249 # Build a 10-commit chain where the file changes only on commit 5.
250 v1 = "\n".join(f"original line {i}" for i in range(5)).encode() + b"\n"
251 v2 = "\n".join(f"changed line {i}" for i in range(5)).encode() + b"\n"
252
253 obj_v1 = _write_object(repo, v1)
254 obj_v2 = _write_object(repo, v2)
255
256 prev = None
257 commit_ids = []
258 for i in range(10):
259 obj = obj_v2 if i < 5 else obj_v1 # file changes at commit 5
260 snap_id = _write_snapshot(repo, {"tracked.txt": obj})
261 cid = _write_commit(repo, snap_id, message=f"c{i}", parent=prev)
262 commit_ids.append(cid)
263 prev = cid
264
265 head = commit_ids[-1]
266
267 call_count = 0
268 original = blame_module._read_file_at_commit
269
270 def counting_read(root, commit_id, rel_path):
271 nonlocal call_count
272 call_count += 1
273 return original(root, commit_id, rel_path)
274
275 with patch.object(blame_module, "_read_file_at_commit", side_effect=counting_read):
276 result = blame_file(repo, "tracked.txt", head)
277
278 assert result is not None
279 # Should read at most once per distinct object_id (2 versions) plus the
280 # initial read, not once per commit in the chain (10).
281 assert call_count <= 4, (
282 f"_read_file_at_commit called {call_count}× for 10 commits with "
283 "only 1 content change — unchanged commits should be skipped"
284 )
285
286
287 class TestRegisterFlags:
288 def test_json_short_flag(self):
289 import argparse
290 from muse.cli.commands.core_blame import register
291 p = argparse.ArgumentParser()
292 subs = p.add_subparsers()
293 register(subs)
294 args = p.parse_args(['blame', 'file.py', '-j'])
295 assert args.json_out is True
296
297 def test_json_long_flag(self):
298 import argparse
299 from muse.cli.commands.core_blame import register
300 p = argparse.ArgumentParser()
301 subs = p.add_subparsers()
302 register(subs)
303 args = p.parse_args(['blame', 'file.py', '--json'])
304 assert args.json_out is True
305
306 def test_default_no_json(self):
307 import argparse
308 from muse.cli.commands.core_blame import register
309 p = argparse.ArgumentParser()
310 subs = p.add_subparsers()
311 register(subs)
312 # Command-specific required args may differ; just check dest exists when possible
313 try:
314 args = p.parse_args(['blame', 'file.py'])
315 assert args.json_out is False
316 except SystemExit:
317 pass # required positional args missing — flag default still correct
File History 3 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 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago