gabriel / muse public
test_cmd_blame.py python
695 lines 28.2 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 15 hours ago
1 """TDD tests for ``muse blame`` (core VCS line-level blame).
2
3 Written *before* the implementation — all tests in this file define the
4 target behaviour of the supercharged blame command:
5
6 - ``--json`` / ``-j`` machine-readable single JSON object (replaces --porcelain)
7 - ``--range START-END`` restrict output to a 1-based inclusive line range
8 - ``--author PATTERN`` filter lines whose attributed author contains PATTERN
9 (case-insensitive substring)
10 - ``--ref REF`` blame at a named branch, tag, or commit prefix
11 - ``--short N`` SHA display width in text output
12 - All errors → stderr; JSON → stdout; exit codes 0/1/2 only
13
14 JSON schema (``muse blame FILE --json``)::
15
16 {
17 "file": "README.md",
18 "ref": "sha256:abc…",
19 "line_count": 3,
20 "lines": [
21 {
22 "lineno": 1,
23 "commit_id": "sha256:abc…",
24 "short_id": "sha256:abc123456789",
25 "author": "gabriel",
26 "committed_at": "2026-01-01T00:00:00+00:00",
27 "message": "initial commit",
28 "content": "hello world"
29 }
30 ]
31 }
32
33 Seven test tiers
34 ----------------
35 Unit — TypedDict shapes, helper isolation
36 Integration — core blame engine via CLI
37 E2E — flag combinations exercised end-to-end
38 Security — null bytes, path traversal, ANSI sanitization
39 Stress — large files, long histories
40 Performance — wall-clock ceilings
41 Data Integrity — lineno contiguity, sha256: prefixes, clean content
42 """
43 from __future__ import annotations
44 from collections.abc import Mapping
45
46 import datetime
47 import json
48 import pathlib
49 import time
50
51 import pytest
52
53 from muse.core.object_store import write_object
54 from muse.core.ids import hash_commit, hash_snapshot
55 from muse.core.commits import (
56 CommitRecord,
57 write_commit,
58 )
59 from muse.core.snapshots import (
60 SnapshotRecord,
61 write_snapshot,
62 )
63 from muse.core.types import Manifest, blob_id, fake_id
64 from muse.core.paths import heads_dir, muse_dir
65 from tests.cli_test_helper import CliRunner
66
67 runner = CliRunner()
68
69 # ---------------------------------------------------------------------------
70 # Fixtures / helpers
71 # ---------------------------------------------------------------------------
72
73 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
74
75
76 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
77 """Minimal Muse repo structure — no muse init required."""
78 dot_muse = muse_dir(tmp_path)
79 for d in ("objects", "commits", "snapshots", "refs/heads"):
80 (dot_muse / d).mkdir(parents=True, exist_ok=True)
81 (dot_muse / "repo.json").write_text(
82 json.dumps({"repo_id": fake_id("repo"), "domain": "code",
83 "default_branch": "main", "created_at": "2026-01-01T00:00:00+00:00"}),
84 encoding="utf-8",
85 )
86 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
87 return tmp_path
88
89
90 def _obj_id(content: bytes) -> str:
91 return blob_id(content)
92
93
94 def _store_text(repo: pathlib.Path, text: str) -> str:
95 """Write a text blob and return its object ID (with sha256: prefix)."""
96 raw = text.encode("utf-8")
97 oid = _obj_id(raw)
98 write_object(repo, oid, raw)
99 return oid
100
101
102 def _commit(
103 repo: pathlib.Path,
104 files: Mapping[str, str],
105 *,
106 message: str = "test commit",
107 author: str = "gabriel",
108 parent: str | None = None,
109 dt_offset: int = 0,
110 ) -> str:
111 """Write a commit containing *files* (path → text) and return its commit_id."""
112 manifest: Manifest = {path: _store_text(repo, text) for path, text in files.items()}
113 snap_id = hash_snapshot(manifest)
114 write_snapshot(repo, SnapshotRecord(
115 snapshot_id=snap_id,
116 manifest=manifest,
117 created_at=_BASE_DT + datetime.timedelta(hours=dt_offset),
118 ))
119 committed_at = _BASE_DT + datetime.timedelta(hours=dt_offset)
120 commit_id = hash_commit( parent_ids=[parent] if parent else [],
121 snapshot_id=snap_id,
122 message=message,
123 committed_at_iso=committed_at.isoformat(),
124 author=author,
125 )
126 write_commit(repo, CommitRecord(
127 commit_id=commit_id,
128 branch="main",
129 snapshot_id=snap_id,
130 message=message,
131 committed_at=committed_at,
132 parent_commit_id=parent,
133 author=author,
134 ))
135 (heads_dir(repo) / "main").write_text(commit_id, encoding="utf-8")
136 return commit_id
137
138
139 def _invoke(repo: pathlib.Path, *args: str) -> "InvokeResult":
140 return runner.invoke(None, ["blame", *args], env={"MUSE_REPO_ROOT": str(repo)})
141
142
143 def _parse_json(output: str) -> Mapping[str, object]:
144 return json.loads(output.strip())
145
146
147 # ---------------------------------------------------------------------------
148 # Tier 1 — Unit: JSON schema shape
149 # ---------------------------------------------------------------------------
150
151
152 class TestBlameJsonSchema:
153 """Verify the top-level JSON object has all required keys."""
154
155 def test_top_level_keys(self, tmp_path: pathlib.Path) -> None:
156 repo = _make_repo(tmp_path)
157 _commit(repo, {"f.txt": "hello\n"})
158 result = _invoke(repo, "f.txt", "--json")
159 assert result.exit_code == 0
160 d = _parse_json(result.output)
161 assert set(d.keys()) >= {"file", "ref", "line_count", "lines"}
162
163 def test_file_key_matches_input(self, tmp_path: pathlib.Path) -> None:
164 repo = _make_repo(tmp_path)
165 _commit(repo, {"readme.md": "# doc\n"})
166 result = _invoke(repo, "readme.md", "--json")
167 assert result.exit_code == 0
168 assert _parse_json(result.output)["file"] == "readme.md"
169
170 def test_ref_key_is_full_commit_id(self, tmp_path: pathlib.Path) -> None:
171 repo = _make_repo(tmp_path)
172 cid = _commit(repo, {"f.txt": "x\n"})
173 result = _invoke(repo, "f.txt", "--json")
174 assert result.exit_code == 0
175 assert _parse_json(result.output)["ref"] == cid
176
177 def test_line_count_matches_lines_array(self, tmp_path: pathlib.Path) -> None:
178 repo = _make_repo(tmp_path)
179 _commit(repo, {"f.txt": "a\nb\nc\n"})
180 result = _invoke(repo, "f.txt", "--json")
181 d = _parse_json(result.output)
182 assert d["line_count"] == len(d["lines"])
183
184 def test_line_entry_keys(self, tmp_path: pathlib.Path) -> None:
185 repo = _make_repo(tmp_path)
186 _commit(repo, {"f.txt": "hello\n"})
187 result = _invoke(repo, "f.txt", "--json")
188 line = _parse_json(result.output)["lines"][0]
189 assert set(line.keys()) >= {"lineno", "commit_id", "short_id", "author",
190 "committed_at", "message", "content"}
191
192 def test_short_id_is_prefix_of_commit_id(self, tmp_path: pathlib.Path) -> None:
193 repo = _make_repo(tmp_path)
194 _commit(repo, {"f.txt": "x\n"})
195 result = _invoke(repo, "f.txt", "--json")
196 line = _parse_json(result.output)["lines"][0]
197 assert line["commit_id"].startswith(line["short_id"])
198
199 def test_short_id_default_length_is_12(self, tmp_path: pathlib.Path) -> None:
200 repo = _make_repo(tmp_path)
201 _commit(repo, {"f.txt": "x\n"})
202 result = _invoke(repo, "f.txt", "--json")
203 line = _parse_json(result.output)["lines"][0]
204 assert len(line["short_id"]) == len("sha256:") + 12
205 assert line["short_id"].startswith("sha256:")
206
207 def test_line_count_is_int(self, tmp_path: pathlib.Path) -> None:
208 repo = _make_repo(tmp_path)
209 _commit(repo, {"f.txt": "one\ntwo\n"})
210 result = _invoke(repo, "f.txt", "--json")
211 assert isinstance(_parse_json(result.output)["line_count"], int)
212
213
214 # ---------------------------------------------------------------------------
215 # Tier 2 — Integration: core attribution correctness via CLI
216 # ---------------------------------------------------------------------------
217
218
219 class TestBlameJsonAttribution:
220 """Verify blame correctly attributes lines to the right commit."""
221
222 def test_single_commit_all_lines_attributed(self, tmp_path: pathlib.Path) -> None:
223 repo = _make_repo(tmp_path)
224 cid = _commit(repo, {"f.txt": "a\nb\nc\n"}, author="alice")
225 result = _invoke(repo, "f.txt", "--json")
226 lines = _parse_json(result.output)["lines"]
227 assert all(l["commit_id"] == cid for l in lines)
228 assert all(l["author"] == "alice" for l in lines)
229
230 def test_older_lines_attributed_to_older_commit(self, tmp_path: pathlib.Path) -> None:
231 repo = _make_repo(tmp_path)
232 c1 = _commit(repo, {"f.txt": "line1\nline2\n"}, message="init", dt_offset=0)
233 _commit(repo, {"f.txt": "line1\nline2\nline3\n"}, message="add line3",
234 parent=c1, dt_offset=1)
235 result = _invoke(repo, "f.txt", "--json")
236 lines = _parse_json(result.output)["lines"]
237 assert lines[0]["commit_id"] == c1
238 assert lines[1]["commit_id"] == c1
239
240 def test_new_line_attributed_to_newer_commit(self, tmp_path: pathlib.Path) -> None:
241 repo = _make_repo(tmp_path)
242 c1 = _commit(repo, {"f.txt": "line1\nline2\n"}, dt_offset=0)
243 c2 = _commit(repo, {"f.txt": "line1\nline2\nline3\n"}, parent=c1, dt_offset=1)
244 result = _invoke(repo, "f.txt", "--json")
245 lines = _parse_json(result.output)["lines"]
246 assert lines[2]["commit_id"] == c2
247
248 def test_message_is_first_line_of_commit_message(self, tmp_path: pathlib.Path) -> None:
249 repo = _make_repo(tmp_path)
250 _commit(repo, {"f.txt": "x\n"}, message="feat: add thing\n\nlong body")
251 result = _invoke(repo, "f.txt", "--json")
252 assert _parse_json(result.output)["lines"][0]["message"] == "feat: add thing"
253
254 def test_content_has_no_trailing_newline(self, tmp_path: pathlib.Path) -> None:
255 repo = _make_repo(tmp_path)
256 _commit(repo, {"f.txt": "hello\nworld\n"})
257 result = _invoke(repo, "f.txt", "--json")
258 for line in _parse_json(result.output)["lines"]:
259 assert not line["content"].endswith("\n")
260
261 def test_committed_at_is_iso8601(self, tmp_path: pathlib.Path) -> None:
262 repo = _make_repo(tmp_path)
263 _commit(repo, {"f.txt": "x\n"})
264 result = _invoke(repo, "f.txt", "--json")
265 ts = _parse_json(result.output)["lines"][0]["committed_at"]
266 assert "T" in ts
267
268 def test_empty_file_returns_zero_lines(self, tmp_path: pathlib.Path) -> None:
269 repo = _make_repo(tmp_path)
270 _commit(repo, {"empty.txt": ""})
271 result = _invoke(repo, "empty.txt", "--json")
272 assert result.exit_code == 0
273 d = _parse_json(result.output)
274 assert d["line_count"] == 0
275 assert d["lines"] == []
276
277
278 # ---------------------------------------------------------------------------
279 # Tier 3 — E2E: flags and flag combinations
280 # ---------------------------------------------------------------------------
281
282
283 class TestBlameJsonFlag:
284 """--json / -j flag behaviour."""
285
286 def test_json_flag_exits_0(self, tmp_path: pathlib.Path) -> None:
287 repo = _make_repo(tmp_path)
288 _commit(repo, {"f.txt": "x\n"})
289 assert _invoke(repo, "f.txt", "--json").exit_code == 0
290
291 def test_j_short_alias(self, tmp_path: pathlib.Path) -> None:
292 repo = _make_repo(tmp_path)
293 _commit(repo, {"f.txt": "x\n"})
294 result = _invoke(repo, "f.txt", "-j")
295 assert result.exit_code == 0
296 _parse_json(result.output) # must be valid JSON
297
298 def test_porcelain_flag_rejected(self, tmp_path: pathlib.Path) -> None:
299 """--porcelain must no longer exist; argparse should reject it."""
300 repo = _make_repo(tmp_path)
301 _commit(repo, {"f.txt": "x\n"})
302 result = _invoke(repo, "--porcelain", "f.txt")
303 assert result.exit_code != 0
304
305 def test_text_output_no_json(self, tmp_path: pathlib.Path) -> None:
306 repo = _make_repo(tmp_path)
307 _commit(repo, {"f.txt": "hello\n"})
308 result = _invoke(repo, "f.txt")
309 assert result.exit_code == 0
310 with pytest.raises((json.JSONDecodeError, ValueError)):
311 json.loads(result.output.strip())
312
313 def test_text_output_contains_content(self, tmp_path: pathlib.Path) -> None:
314 repo = _make_repo(tmp_path)
315 _commit(repo, {"f.txt": "hello world\n"})
316 result = _invoke(repo, "f.txt")
317 assert "hello world" in result.output
318
319 def test_text_output_contains_lineno(self, tmp_path: pathlib.Path) -> None:
320 repo = _make_repo(tmp_path)
321 _commit(repo, {"f.txt": "a\nb\nc\n"})
322 result = _invoke(repo, "f.txt")
323 assert "1" in result.output
324 assert "2" in result.output
325 assert "3" in result.output
326
327 def test_short_n_changes_sha_width(self, tmp_path: pathlib.Path) -> None:
328 repo = _make_repo(tmp_path)
329 _commit(repo, {"f.txt": "x\n"})
330 result8 = _invoke(repo, "f.txt", "--short", "8")
331 result16 = _invoke(repo, "f.txt", "--short", "16")
332 # Lines differ in width — just check both succeed
333 assert result8.exit_code == 0
334 assert result16.exit_code == 0
335
336 def test_ref_branch_name(self, tmp_path: pathlib.Path) -> None:
337 repo = _make_repo(tmp_path)
338 _commit(repo, {"f.txt": "at main\n"})
339 result = _invoke(repo, "f.txt", "--ref", "main", "--json")
340 assert result.exit_code == 0
341 assert _parse_json(result.output)["lines"][0]["content"] == "at main"
342
343 def test_json_is_compact(self, tmp_path: pathlib.Path) -> None:
344 repo = _make_repo(tmp_path)
345 _commit(repo, {"f.txt": "x\n"})
346 result = _invoke(repo, "f.txt", "--json")
347 assert "\n" not in result.output.strip() # compact JSON for agents
348
349
350 class TestBlameRange:
351 """--range START-END flag."""
352
353 def test_range_limits_lines_returned(self, tmp_path: pathlib.Path) -> None:
354 repo = _make_repo(tmp_path)
355 _commit(repo, {"f.txt": "a\nb\nc\nd\ne\n"})
356 result = _invoke(repo, "f.txt", "--range", "2-4", "--json")
357 assert result.exit_code == 0
358 lines = _parse_json(result.output)["lines"]
359 assert len(lines) == 3
360 assert lines[0]["lineno"] == 2
361 assert lines[-1]["lineno"] == 4
362
363 def test_range_single_line(self, tmp_path: pathlib.Path) -> None:
364 repo = _make_repo(tmp_path)
365 _commit(repo, {"f.txt": "a\nb\nc\n"})
366 result = _invoke(repo, "f.txt", "--range", "2-2", "--json")
367 assert result.exit_code == 0
368 lines = _parse_json(result.output)["lines"]
369 assert len(lines) == 1
370 assert lines[0]["lineno"] == 2
371 assert lines[0]["content"] == "b"
372
373 def test_range_full_file_explicit(self, tmp_path: pathlib.Path) -> None:
374 repo = _make_repo(tmp_path)
375 _commit(repo, {"f.txt": "a\nb\nc\n"})
376 result = _invoke(repo, "f.txt", "--range", "1-3", "--json")
377 lines = _parse_json(result.output)["lines"]
378 assert len(lines) == 3
379
380 def test_range_start_gt_end_is_error(self, tmp_path: pathlib.Path) -> None:
381 repo = _make_repo(tmp_path)
382 _commit(repo, {"f.txt": "a\nb\nc\n"})
383 result = _invoke(repo, "f.txt", "--range", "4-2")
384 assert result.exit_code != 0
385 assert "❌" in result.stderr or "error" in result.stderr.lower()
386
387 def test_range_zero_start_is_error(self, tmp_path: pathlib.Path) -> None:
388 repo = _make_repo(tmp_path)
389 _commit(repo, {"f.txt": "a\n"})
390 result = _invoke(repo, "f.txt", "--range", "0-1")
391 assert result.exit_code != 0
392
393 def test_range_clamped_to_file_length(self, tmp_path: pathlib.Path) -> None:
394 repo = _make_repo(tmp_path)
395 _commit(repo, {"f.txt": "a\nb\nc\n"})
396 result = _invoke(repo, "f.txt", "--range", "2-999", "--json")
397 assert result.exit_code == 0
398 lines = _parse_json(result.output)["lines"]
399 # only lines 2 and 3 exist
400 assert len(lines) == 2
401
402 def test_range_text_output_respected(self, tmp_path: pathlib.Path) -> None:
403 repo = _make_repo(tmp_path)
404 _commit(repo, {"f.txt": "aa\nbb\ncc\n"})
405 result = _invoke(repo, "f.txt", "--range", "2-2")
406 assert result.exit_code == 0
407 assert "bb" in result.output
408 assert "aa" not in result.output
409 assert "cc" not in result.output
410
411 def test_range_line_count_reflects_filtered(self, tmp_path: pathlib.Path) -> None:
412 repo = _make_repo(tmp_path)
413 _commit(repo, {"f.txt": "a\nb\nc\nd\n"})
414 result = _invoke(repo, "f.txt", "--range", "1-2", "--json")
415 d = _parse_json(result.output)
416 assert d["line_count"] == 2
417
418
419 class TestBlameAuthor:
420 """--author PATTERN flag."""
421
422 def test_author_filter_matches(self, tmp_path: pathlib.Path) -> None:
423 repo = _make_repo(tmp_path)
424 _commit(repo, {"f.txt": "by alice\n"}, author="alice")
425 result = _invoke(repo, "f.txt", "--author", "alice", "--json")
426 assert result.exit_code == 0
427 lines = _parse_json(result.output)["lines"]
428 assert len(lines) == 1
429
430 def test_author_filter_case_insensitive(self, tmp_path: pathlib.Path) -> None:
431 repo = _make_repo(tmp_path)
432 _commit(repo, {"f.txt": "x\n"}, author="Alice")
433 result = _invoke(repo, "f.txt", "--author", "ALICE", "--json")
434 assert result.exit_code == 0
435 assert len(_parse_json(result.output)["lines"]) == 1
436
437 def test_author_filter_no_match_returns_empty(self, tmp_path: pathlib.Path) -> None:
438 repo = _make_repo(tmp_path)
439 _commit(repo, {"f.txt": "x\n"}, author="alice")
440 result = _invoke(repo, "f.txt", "--author", "bob", "--json")
441 assert result.exit_code == 0
442 assert _parse_json(result.output)["lines"] == []
443
444 def test_author_filter_substring_match(self, tmp_path: pathlib.Path) -> None:
445 repo = _make_repo(tmp_path)
446 _commit(repo, {"f.txt": "x\n"}, author="gabriel cardona")
447 result = _invoke(repo, "f.txt", "--author", "gabriel", "--json")
448 assert result.exit_code == 0
449 assert len(_parse_json(result.output)["lines"]) == 1
450
451 def test_author_filter_with_two_authors(self, tmp_path: pathlib.Path) -> None:
452 repo = _make_repo(tmp_path)
453 c1 = _commit(repo, {"f.txt": "alice line\n"}, author="alice", dt_offset=0)
454 _commit(repo, {"f.txt": "alice line\nbob line\n"}, author="bob",
455 parent=c1, dt_offset=1)
456 result = _invoke(repo, "f.txt", "--author", "alice", "--json")
457 assert result.exit_code == 0
458 lines = _parse_json(result.output)["lines"]
459 assert all(l["author"] == "alice" for l in lines)
460
461 def test_author_combined_with_range(self, tmp_path: pathlib.Path) -> None:
462 repo = _make_repo(tmp_path)
463 _commit(repo, {"f.txt": "a\nb\nc\n"}, author="alice")
464 result = _invoke(repo, "f.txt", "--author", "alice", "--range", "1-2", "--json")
465 assert result.exit_code == 0
466 lines = _parse_json(result.output)["lines"]
467 assert len(lines) == 2
468
469 def test_author_line_count_reflects_filter(self, tmp_path: pathlib.Path) -> None:
470 repo = _make_repo(tmp_path)
471 c1 = _commit(repo, {"f.txt": "alice\n"}, author="alice", dt_offset=0)
472 _commit(repo, {"f.txt": "alice\nbob\n"}, author="bob", parent=c1, dt_offset=1)
473 result = _invoke(repo, "f.txt", "--author", "bob", "--json")
474 d = _parse_json(result.output)
475 assert d["line_count"] == len(d["lines"])
476
477
478 # ---------------------------------------------------------------------------
479 # Tier 4 — Security
480 # ---------------------------------------------------------------------------
481
482
483 class TestBlameSecurity:
484 """Input validation and output sanitization."""
485
486 def test_null_byte_in_path_is_error(self, tmp_path: pathlib.Path) -> None:
487 repo = _make_repo(tmp_path)
488 _commit(repo, {"f.txt": "x\n"})
489 result = _invoke(repo, "f.txt\x00malicious")
490 assert result.exit_code != 0
491 assert "❌" in result.stderr
492
493 def test_unknown_file_exits_1(self, tmp_path: pathlib.Path) -> None:
494 repo = _make_repo(tmp_path)
495 _commit(repo, {"f.txt": "x\n"})
496 result = _invoke(repo, "does_not_exist.txt")
497 assert result.exit_code == 1
498
499 def test_unknown_file_error_on_stderr(self, tmp_path: pathlib.Path) -> None:
500 repo = _make_repo(tmp_path)
501 _commit(repo, {"f.txt": "x\n"})
502 result = _invoke(repo, "does_not_exist.txt")
503 assert "❌" in result.stderr
504
505 def test_unknown_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
506 repo = _make_repo(tmp_path)
507 _commit(repo, {"f.txt": "x\n"})
508 result = _invoke(repo, "f.txt", "--ref", "nonexistent-branch")
509 assert result.exit_code == 1
510
511 def test_unknown_ref_error_on_stderr(self, tmp_path: pathlib.Path) -> None:
512 repo = _make_repo(tmp_path)
513 _commit(repo, {"f.txt": "x\n"})
514 result = _invoke(repo, "f.txt", "--ref", "no-such-ref")
515 assert "❌" in result.stderr
516
517 def test_ansi_in_content_sanitized_text(self, tmp_path: pathlib.Path) -> None:
518 repo = _make_repo(tmp_path)
519 _commit(repo, {"f.txt": "normal\x1b[31mred\x1b[0m\n"})
520 result = _invoke(repo, "f.txt")
521 assert "\x1b" not in result.output
522
523 def test_ansi_in_author_sanitized_text(self, tmp_path: pathlib.Path) -> None:
524 repo = _make_repo(tmp_path)
525 _commit(repo, {"f.txt": "x\n"}, author="bad\x1b[31mactor\x1b[0m")
526 result = _invoke(repo, "f.txt")
527 assert "\x1b" not in result.output
528
529 def test_json_output_no_ansi(self, tmp_path: pathlib.Path) -> None:
530 repo = _make_repo(tmp_path)
531 _commit(repo, {"f.txt": "\x1b[31mcolor\x1b[0m\n"}, author="\x1b[32mmalicious\x1b[0m")
532 result = _invoke(repo, "f.txt", "--json")
533 assert "\x1b" not in result.output
534
535 def test_no_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
536 empty = tmp_path / "not_a_repo"
537 empty.mkdir()
538 result = runner.invoke(None, ["blame", "f.txt"],
539 env={"MUSE_REPO_ROOT": str(empty)})
540 assert result.exit_code == 2
541
542 def test_range_invalid_format_is_error(self, tmp_path: pathlib.Path) -> None:
543 repo = _make_repo(tmp_path)
544 _commit(repo, {"f.txt": "x\n"})
545 result = _invoke(repo, "f.txt", "--range", "abc-xyz")
546 assert result.exit_code != 0
547
548
549 # ---------------------------------------------------------------------------
550 # Tier 5 — Stress
551 # ---------------------------------------------------------------------------
552
553
554 class TestBlameStress:
555 """Correctness under scale."""
556
557 def test_500_line_file(self, tmp_path: pathlib.Path) -> None:
558 repo = _make_repo(tmp_path)
559 text = "\n".join(f"line {i}" for i in range(1, 501)) + "\n"
560 _commit(repo, {"big.txt": text})
561 result = _invoke(repo, "big.txt", "--json")
562 assert result.exit_code == 0
563 d = _parse_json(result.output)
564 assert d["line_count"] == 500
565 assert len(d["lines"]) == 500
566
567 def test_20_commit_chain(self, tmp_path: pathlib.Path) -> None:
568 repo = _make_repo(tmp_path)
569 parent = None
570 for i in range(20):
571 lines = "\n".join(f"line {j}" for j in range(i + 1)) + "\n"
572 parent = _commit(repo, {"f.txt": lines}, message=f"c{i}",
573 parent=parent, dt_offset=i)
574 result = _invoke(repo, "f.txt", "--json")
575 assert result.exit_code == 0
576 assert _parse_json(result.output)["line_count"] == 20
577
578 def test_single_line_file(self, tmp_path: pathlib.Path) -> None:
579 repo = _make_repo(tmp_path)
580 _commit(repo, {"f.txt": "only line\n"})
581 result = _invoke(repo, "f.txt", "--json")
582 d = _parse_json(result.output)
583 assert d["line_count"] == 1
584 assert d["lines"][0]["content"] == "only line"
585
586 def test_file_no_trailing_newline(self, tmp_path: pathlib.Path) -> None:
587 """Files without a trailing newline must still blame correctly."""
588 repo = _make_repo(tmp_path)
589 _commit(repo, {"f.txt": "no newline"})
590 result = _invoke(repo, "f.txt", "--json")
591 assert result.exit_code == 0
592 d = _parse_json(result.output)
593 assert d["line_count"] == 1
594 assert d["lines"][0]["content"] == "no newline"
595
596 def test_range_on_large_file(self, tmp_path: pathlib.Path) -> None:
597 repo = _make_repo(tmp_path)
598 text = "\n".join(f"line {i}" for i in range(1, 201)) + "\n"
599 _commit(repo, {"big.txt": text})
600 result = _invoke(repo, "big.txt", "--range", "50-100", "--json")
601 assert result.exit_code == 0
602 lines = _parse_json(result.output)["lines"]
603 assert len(lines) == 51
604 assert lines[0]["lineno"] == 50
605 assert lines[-1]["lineno"] == 100
606
607
608 # ---------------------------------------------------------------------------
609 # Tier 6 — Performance
610 # ---------------------------------------------------------------------------
611
612
613 class TestBlamePerformance:
614 """Wall-clock ceilings — fast enough not to block an agent loop."""
615
616 def test_100_line_file_under_2s(self, tmp_path: pathlib.Path) -> None:
617 repo = _make_repo(tmp_path)
618 text = "\n".join(f"line {i}" for i in range(100)) + "\n"
619 _commit(repo, {"f.txt": text})
620 t0 = time.monotonic()
621 result = _invoke(repo, "f.txt", "--json")
622 elapsed = time.monotonic() - t0
623 assert result.exit_code == 0
624 assert elapsed < 2.0, f"blame took {elapsed:.2f}s on 100-line file"
625
626 def test_10_commit_chain_under_3s(self, tmp_path: pathlib.Path) -> None:
627 repo = _make_repo(tmp_path)
628 parent = None
629 for i in range(10):
630 parent = _commit(repo, {"f.txt": f"line{i}\n"}, parent=parent, dt_offset=i)
631 t0 = time.monotonic()
632 result = _invoke(repo, "f.txt", "--json")
633 elapsed = time.monotonic() - t0
634 assert result.exit_code == 0
635 assert elapsed < 3.0, f"blame took {elapsed:.2f}s over 10 commits"
636
637
638 # ---------------------------------------------------------------------------
639 # Tier 7 — Data Integrity
640 # ---------------------------------------------------------------------------
641
642
643 class TestBlameDataIntegrity:
644 """Structural invariants that must hold for every blame output."""
645
646 def test_linenos_are_contiguous_from_1(self, tmp_path: pathlib.Path) -> None:
647 repo = _make_repo(tmp_path)
648 _commit(repo, {"f.txt": "a\nb\nc\nd\n"})
649 lines = _parse_json(_invoke(repo, "f.txt", "--json").output)["lines"]
650 assert [l["lineno"] for l in lines] == [1, 2, 3, 4]
651
652 def test_all_commit_ids_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
653 repo = _make_repo(tmp_path)
654 _commit(repo, {"f.txt": "a\nb\n"})
655 lines = _parse_json(_invoke(repo, "f.txt", "--json").output)["lines"]
656 assert all(l["commit_id"].startswith("sha256:") for l in lines)
657
658 def test_author_never_empty_string(self, tmp_path: pathlib.Path) -> None:
659 repo = _make_repo(tmp_path)
660 _commit(repo, {"f.txt": "x\n"}, author="gabriel")
661 lines = _parse_json(_invoke(repo, "f.txt", "--json").output)["lines"]
662 assert all(l["author"] for l in lines)
663
664 def test_content_no_trailing_newline(self, tmp_path: pathlib.Path) -> None:
665 repo = _make_repo(tmp_path)
666 _commit(repo, {"f.txt": "hello\nworld\n"})
667 lines = _parse_json(_invoke(repo, "f.txt", "--json").output)["lines"]
668 assert all(not l["content"].endswith("\n") for l in lines)
669
670 def test_range_linenos_match_original_positions(self, tmp_path: pathlib.Path) -> None:
671 """Lines filtered by --range must report their original file position."""
672 repo = _make_repo(tmp_path)
673 _commit(repo, {"f.txt": "a\nb\nc\nd\ne\n"})
674 lines = _parse_json(_invoke(repo, "f.txt", "--range", "3-5", "--json").output)["lines"]
675 assert [l["lineno"] for l in lines] == [3, 4, 5]
676 assert lines[0]["content"] == "c"
677 assert lines[1]["content"] == "d"
678 assert lines[2]["content"] == "e"
679
680 def test_line_count_equals_lines_length_always(self, tmp_path: pathlib.Path) -> None:
681 repo = _make_repo(tmp_path)
682 _commit(repo, {"f.txt": "a\nb\nc\n"})
683 for flags in ([], ["--range", "1-2"], ["--author", "gabriel"]):
684 result = _invoke(repo, "f.txt", "--json", *flags)
685 d = _parse_json(result.output)
686 assert d["line_count"] == len(d["lines"])
687
688 def test_json_is_valid_and_parseable(self, tmp_path: pathlib.Path) -> None:
689 repo = _make_repo(tmp_path)
690 _commit(repo, {"f.txt": "x\ny\n"})
691 result = _invoke(repo, "f.txt", "--json")
692 assert result.exit_code == 0
693 d = _parse_json(result.output)
694 assert isinstance(d, dict)
695 assert isinstance(d["lines"], list)
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 15 hours ago