cgcardona / muse public
test_core_blame.py python
224 lines 7.3 KB
e0353dfe feat: muse reflog, gc, archive, bisect, blame, worktree, workspace Gabriel Cardona <cgcardona@gmail.com> 6h ago
1 """Tests for muse/core/blame.py — line-level text attribution."""
2
3 from __future__ import annotations
4
5 import hashlib
6 import json
7 import pathlib
8
9 import pytest
10
11 from muse.core.blame import BlameLine, blame_file
12
13
14 # ---------------------------------------------------------------------------
15 # Helpers
16 # ---------------------------------------------------------------------------
17
18
19 def _sha256(content: bytes) -> str:
20 return hashlib.sha256(content).hexdigest()
21
22
23 def _write_object(repo: pathlib.Path, content: bytes) -> str:
24 sha = _sha256(content)
25 obj_dir = repo / ".muse" / "objects" / sha[:2]
26 obj_dir.mkdir(parents=True, exist_ok=True)
27 (obj_dir / sha[2:]).write_bytes(content)
28 return sha
29
30
31 def _write_snapshot(repo: pathlib.Path, snap_id: str, manifest: dict[str, str]) -> None:
32 snap_dir = repo / ".muse" / "snapshots"
33 snap_dir.mkdir(parents=True, exist_ok=True)
34 (snap_dir / f"{snap_id}.json").write_text(
35 json.dumps({"snapshot_id": snap_id, "manifest": manifest})
36 )
37
38
39 def _write_commit(
40 repo: pathlib.Path,
41 commit_id: str,
42 snap_id: str,
43 message: str = "test",
44 parent: str | None = None,
45 author: str = "Author",
46 ) -> None:
47 import datetime
48
49 commit_dir = repo / ".muse" / "commits"
50 commit_dir.mkdir(parents=True, exist_ok=True)
51 rec = {
52 "commit_id": commit_id,
53 "repo_id": "test-repo",
54 "branch": "main",
55 "snapshot_id": snap_id,
56 "message": message,
57 "committed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
58 "parent_commit_id": parent,
59 "parent2_commit_id": None,
60 "author": author,
61 "metadata": {},
62 }
63 (commit_dir / f"{commit_id}.json").write_text(json.dumps(rec))
64
65
66 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
67 muse = tmp_path / ".muse"
68 for d in ("objects", "commits", "snapshots", "refs/heads"):
69 (muse / d).mkdir(parents=True, exist_ok=True)
70 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
71 (muse / "HEAD").write_text("refs/heads/main\n")
72 return tmp_path
73
74
75 # ---------------------------------------------------------------------------
76 # Tests
77 # ---------------------------------------------------------------------------
78
79
80 def test_blame_returns_none_for_missing_file(tmp_path: pathlib.Path) -> None:
81 repo = _make_repo(tmp_path)
82 snap_id = "s" * 64
83 commit_id = "c" * 64
84 _write_snapshot(repo, snap_id, {}) # empty manifest
85 _write_commit(repo, commit_id, snap_id)
86
87 result = blame_file(repo, "nonexistent.txt", commit_id)
88 assert result is None
89
90
91 def test_blame_single_commit_all_lines_attributed(tmp_path: pathlib.Path) -> None:
92 repo = _make_repo(tmp_path)
93 content = b"line one\nline two\nline three\n"
94 obj_id = _write_object(repo, content)
95 snap_id = "s" * 64
96 commit_id = "c" * 64
97 _write_snapshot(repo, snap_id, {"readme.txt": obj_id})
98 _write_commit(repo, commit_id, 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 = "s" * 64
113 commit_id = "c" * 64
114 _write_snapshot(repo, snap_id, {"f.txt": obj_id})
115 _write_commit(repo, commit_id, snap_id)
116
117 result = blame_file(repo, "f.txt", commit_id)
118 assert result is not None
119 assert [bl.lineno for bl in result] == [1, 2, 3]
120
121
122 def test_blame_content_matches_file(tmp_path: pathlib.Path) -> None:
123 repo = _make_repo(tmp_path)
124 content = b"hello\nworld\n"
125 obj_id = _write_object(repo, content)
126 snap_id = "s" * 64
127 commit_id = "c" * 64
128 _write_snapshot(repo, snap_id, {"f.txt": obj_id})
129 _write_commit(repo, commit_id, snap_id)
130
131 result = blame_file(repo, "f.txt", commit_id)
132 assert result is not None
133 assert result[0].content == "hello"
134 assert result[1].content == "world"
135
136
137 def test_blame_empty_file_returns_empty_list(tmp_path: pathlib.Path) -> None:
138 repo = _make_repo(tmp_path)
139 content = b""
140 obj_id = _write_object(repo, content)
141 snap_id = "s" * 64
142 commit_id = "c" * 64
143 _write_snapshot(repo, snap_id, {"empty.txt": obj_id})
144 _write_commit(repo, commit_id, snap_id)
145
146 result = blame_file(repo, "empty.txt", commit_id)
147 assert result == []
148
149
150 def test_blame_two_commits_attributes_older_lines_correctly(tmp_path: pathlib.Path) -> None:
151 """Lines present in both commits should be attributed to the older commit."""
152 repo = _make_repo(tmp_path)
153
154 # Commit 1: file with two lines.
155 content1 = b"original line 1\noriginal line 2\n"
156 obj1 = _write_object(repo, content1)
157 snap1 = "1" * 64
158 commit1 = "a" * 64
159 _write_snapshot(repo, snap1, {"f.txt": obj1})
160 _write_commit(repo, commit1, snap1, message="initial", author="Alice")
161
162 # Commit 2: same two lines + one new line.
163 content2 = b"original line 1\noriginal line 2\nnew line 3\n"
164 obj2 = _write_object(repo, content2)
165 snap2 = "2" * 64
166 commit2 = "b" * 64
167 _write_snapshot(repo, snap2, {"f.txt": obj2})
168 _write_commit(repo, commit2, snap2, message="add line 3", parent=commit1, author="Bob")
169
170 result = blame_file(repo, "f.txt", commit2)
171 assert result is not None
172 assert len(result) == 3
173 # Lines 1 and 2 should be attributed to commit1 (they existed before commit2).
174 assert result[0].commit_id == commit1
175 assert result[1].commit_id == commit1
176 # Line 3 was added by commit2.
177 assert result[2].commit_id == commit2
178
179
180 def test_blame_author_populated(tmp_path: pathlib.Path) -> None:
181 repo = _make_repo(tmp_path)
182 obj_id = _write_object(repo, b"line\n")
183 snap_id = "s" * 64
184 commit_id = "c" * 64
185 _write_snapshot(repo, snap_id, {"f.txt": obj_id})
186 _write_commit(repo, commit_id, snap_id, author="Carol")
187
188 result = blame_file(repo, "f.txt", commit_id)
189 assert result is not None
190 assert result[0].author == "Carol"
191
192
193 def test_blame_message_is_first_line_of_commit_message(tmp_path: pathlib.Path) -> None:
194 repo = _make_repo(tmp_path)
195 obj_id = _write_object(repo, b"line\n")
196 snap_id = "s" * 64
197 commit_id = "c" * 64
198 _write_snapshot(repo, snap_id, {"f.txt": obj_id})
199 _write_commit(repo, commit_id, 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_stress_100_line_file(tmp_path: pathlib.Path) -> None:
212 """Blame should handle a 100-line file without errors."""
213 repo = _make_repo(tmp_path)
214 content = "\n".join(f"line {i}" for i in range(100)).encode() + b"\n"
215 obj_id = _write_object(repo, content)
216 snap_id = "s" * 64
217 commit_id = "c" * 64
218 _write_snapshot(repo, snap_id, {"big.txt": obj_id})
219 _write_commit(repo, commit_id, snap_id)
220
221 result = blame_file(repo, "big.txt", commit_id)
222 assert result is not None
223 assert len(result) == 100
224 assert all(bl.commit_id == commit_id for bl in result)