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