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