gabriel / muse public
test_core_blame.py python
277 lines 9.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 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(parent_ids, snap_id, message, dt.isoformat())
50 write_commit(repo, CommitRecord(
51 commit_id=commit_id,
52 repo_id="test-repo",
53 branch="main",
54 snapshot_id=snap_id,
55 message=message,
56 committed_at=dt,
57 parent_commit_id=parent,
58 author=author,
59 ))
60 return commit_id
61
62
63 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
64 muse = tmp_path / ".muse"
65 for d in ("objects", "commits", "snapshots", "refs/heads"):
66 (muse / d).mkdir(parents=True, exist_ok=True)
67 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
68 (muse / "HEAD").write_text("ref: refs/heads/main\n")
69 return tmp_path
70
71
72 # ---------------------------------------------------------------------------
73 # Tests
74 # ---------------------------------------------------------------------------
75
76
77 def test_blame_returns_none_for_missing_file(tmp_path: pathlib.Path) -> None:
78 repo = _make_repo(tmp_path)
79 snap_id = _write_snapshot(repo, {}) # empty manifest
80 commit_id = _write_commit(repo, snap_id)
81
82 result = blame_file(repo, "nonexistent.txt", commit_id)
83 assert result is None
84
85
86 def test_blame_single_commit_all_lines_attributed(tmp_path: pathlib.Path) -> None:
87 repo = _make_repo(tmp_path)
88 content = b"line one\nline two\nline three\n"
89 obj_id = _write_object(repo, content)
90 snap_id = _write_snapshot(repo, {"readme.txt": obj_id})
91 commit_id = _write_commit(repo, snap_id, message="initial commit", author="Alice")
92
93 result = blame_file(repo, "readme.txt", commit_id)
94 assert result is not None
95 assert len(result) == 3
96 for line in result:
97 assert isinstance(line, BlameLine)
98 assert line.commit_id == commit_id
99
100
101 def test_blame_line_numbers_are_1_indexed(tmp_path: pathlib.Path) -> None:
102 repo = _make_repo(tmp_path)
103 content = b"a\nb\nc\n"
104 obj_id = _write_object(repo, content)
105 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
106 commit_id = _write_commit(repo, snap_id)
107
108 result = blame_file(repo, "f.txt", commit_id)
109 assert result is not None
110 assert [bl.lineno for bl in result] == [1, 2, 3]
111
112
113 def test_blame_content_matches_file(tmp_path: pathlib.Path) -> None:
114 repo = _make_repo(tmp_path)
115 content = b"hello\nworld\n"
116 obj_id = _write_object(repo, content)
117 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
118 commit_id = _write_commit(repo, snap_id)
119
120 result = blame_file(repo, "f.txt", commit_id)
121 assert result is not None
122 assert result[0].content == "hello"
123 assert result[1].content == "world"
124
125
126 def test_blame_empty_file_returns_empty_list(tmp_path: pathlib.Path) -> None:
127 repo = _make_repo(tmp_path)
128 content = b""
129 obj_id = _write_object(repo, content)
130 snap_id = _write_snapshot(repo, {"empty.txt": obj_id})
131 commit_id = _write_commit(repo, snap_id)
132
133 result = blame_file(repo, "empty.txt", commit_id)
134 assert result == []
135
136
137 def test_blame_two_commits_attributes_older_lines_correctly(tmp_path: pathlib.Path) -> None:
138 """Lines present in both commits should be attributed to the older commit."""
139 repo = _make_repo(tmp_path)
140
141 # Commit 1: file with two lines.
142 content1 = b"original line 1\noriginal line 2\n"
143 obj1 = _write_object(repo, content1)
144 snap1 = _write_snapshot(repo, {"f.txt": obj1})
145 commit1 = _write_commit(
146 repo, snap1, message="initial", author="Alice",
147 committed_at=_BASE_DT,
148 )
149
150 # Commit 2: same two lines + one new line.
151 content2 = b"original line 1\noriginal line 2\nnew line 3\n"
152 obj2 = _write_object(repo, content2)
153 snap2 = _write_snapshot(repo, {"f.txt": obj2})
154 commit2 = _write_commit(
155 repo, snap2, message="add line 3", parent=commit1, author="Bob",
156 committed_at=_BASE_DT + datetime.timedelta(hours=1),
157 )
158
159 result = blame_file(repo, "f.txt", commit2)
160 assert result is not None
161 assert len(result) == 3
162 # Lines 1 and 2 should be attributed to commit1 (they existed before commit2).
163 assert result[0].commit_id == commit1
164 assert result[1].commit_id == commit1
165 # Line 3 was added by commit2.
166 assert result[2].commit_id == commit2
167
168
169 def test_blame_author_populated(tmp_path: pathlib.Path) -> None:
170 repo = _make_repo(tmp_path)
171 obj_id = _write_object(repo, b"line\n")
172 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
173 commit_id = _write_commit(repo, snap_id, author="Carol")
174
175 result = blame_file(repo, "f.txt", commit_id)
176 assert result is not None
177 assert result[0].author == "Carol"
178
179
180 def test_blame_message_is_first_line_of_commit_message(tmp_path: pathlib.Path) -> None:
181 repo = _make_repo(tmp_path)
182 obj_id = _write_object(repo, b"line\n")
183 snap_id = _write_snapshot(repo, {"f.txt": obj_id})
184 commit_id = _write_commit(repo, snap_id, message="feat: add feature\n\nLong body here.")
185
186 result = blame_file(repo, "f.txt", commit_id)
187 assert result is not None
188 assert result[0].message == "feat: add feature"
189
190
191 # ---------------------------------------------------------------------------
192 # Stress
193 # ---------------------------------------------------------------------------
194
195
196 def test_blame_stress_100_line_file(tmp_path: pathlib.Path) -> None:
197 """Blame should handle a 100-line file without errors."""
198 repo = _make_repo(tmp_path)
199 content = "\n".join(f"line {i}" for i in range(100)).encode() + b"\n"
200 obj_id = _write_object(repo, content)
201 snap_id = _write_snapshot(repo, {"big.txt": obj_id})
202 commit_id = _write_commit(repo, snap_id)
203
204 result = blame_file(repo, "big.txt", commit_id)
205 assert result is not None
206 assert len(result) == 100
207 assert all(bl.commit_id == commit_id for bl in result)
208
209
210 # ---------------------------------------------------------------------------
211 # Performance
212 # ---------------------------------------------------------------------------
213
214
215 def test_walk_ancestry_delegates_to_iter_ancestors(tmp_path: pathlib.Path) -> None:
216 """_walk_ancestry must delegate to graph.iter_ancestors.
217
218 The O(1) deque guarantee is provided by iter_ancestors (verified in
219 test_core_graph.py). This test confirms the delegation is in place so
220 _walk_ancestry cannot silently revert to a home-grown O(n) walk.
221 """
222 import inspect
223 from muse.core import blame as blame_module
224
225 source = inspect.getsource(blame_module._walk_ancestry)
226 assert "iter_ancestors" in source, "_walk_ancestry must delegate to graph.iter_ancestors"
227 assert "pop(0)" not in source, "_walk_ancestry must not use list.pop(0)"
228 assert "insert(0" not in source, "_walk_ancestry must not use list.insert(0, ...)"
229
230
231 def test_blame_skips_read_for_unchanged_commits(tmp_path: pathlib.Path) -> None:
232 """blame_file must skip snapshot reads when the file's object_id is unchanged.
233
234 With 10 commits where the file only changes once, _read_file_at_commit
235 should be called at most twice (at the change boundary), not 10 times.
236 """
237 from unittest.mock import patch
238 from muse.core import blame as blame_module
239
240 repo = _make_repo(tmp_path)
241
242 # Build a 10-commit chain where the file changes only on commit 5.
243 v1 = "\n".join(f"original line {i}" for i in range(5)).encode() + b"\n"
244 v2 = "\n".join(f"changed line {i}" for i in range(5)).encode() + b"\n"
245
246 obj_v1 = _write_object(repo, v1)
247 obj_v2 = _write_object(repo, v2)
248
249 prev = None
250 commit_ids = []
251 for i in range(10):
252 obj = obj_v2 if i < 5 else obj_v1 # file changes at commit 5
253 snap_id = _write_snapshot(repo, {"tracked.txt": obj})
254 cid = _write_commit(repo, snap_id, message=f"c{i}", parent=prev)
255 commit_ids.append(cid)
256 prev = cid
257
258 head = commit_ids[-1]
259
260 call_count = 0
261 original = blame_module._read_file_at_commit
262
263 def counting_read(root, commit_id, rel_path):
264 nonlocal call_count
265 call_count += 1
266 return original(root, commit_id, rel_path)
267
268 with patch.object(blame_module, "_read_file_at_commit", side_effect=counting_read):
269 result = blame_file(repo, "tracked.txt", head)
270
271 assert result is not None
272 # Should read at most once per distinct object_id (2 versions) plus the
273 # initial read, not once per commit in the chain (10).
274 assert call_count <= 4, (
275 f"_read_file_at_commit called {call_count}× for 10 commits with "
276 "only 1 content change — unchanged commits should be skipped"
277 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago