gabriel / muse public
test_format_patch_supercharge.py python
1,021 lines 39.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Supercharge tests for ``muse format-patch``.
2
3 TDD — sections labelled [RED] contain tests that fail until the feature lands.
4 Sections labelled [GREEN] fill gaps against existing behavior.
5
6 New features under test
7 -----------------------
8 - ``--agent-id <id>`` [RED] embed agent provenance in the patch record
9 - ``--model-id <id>`` [RED] embed model provenance in the patch record
10 - ``--intent <text>`` [RED] embed an intent description in the patch record
11 - ``--no-blobs`` [RED] omit base64 blob content from the patch file
12 - Rename detection [RED] same-oid delete+insert → rename op in files_renamed
13
14 Gap-fill coverage
15 -----------------
16 - Register-flag parser shape
17 - Unit tests for _sem_ver_bump, _breaking_changes, _make_patch_filename, _action_label
18 - Blob content verification (decoded bytes match source)
19 - from/to manifest delta correctness
20 - Initial-commit sentinel (from_snapshot_id = sha256:000…, from_commit_id = "")
21 - Required-objects sorted + sha256: prefix
22 - ops count === files_added + files_modified + files_deleted
23 - Default stdout output is valid JSON
24 - Stress: 50-file commit
25 - Security: path-traversal and ANSI in treeish
26 - Performance: duration_ms plausible
27 """
28 from __future__ import annotations
29 from collections.abc import Mapping
30
31 import argparse
32 import datetime
33 import json
34 import pathlib
35 import time
36
37 import pytest
38
39 from muse.cli.commands.format_patch import (
40 _action_label,
41 _breaking_changes,
42 _build_file_level_ops,
43 _make_patch_filename,
44 _sem_ver_bump,
45 register,
46 )
47 from muse.core.object_store import write_object
48 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
49 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
50 from tests.cli_test_helper import CliRunner, InvokeResult
51 from muse.core._types import long_id, blob_id
52
53 runner = CliRunner()
54
55
56 # ---------------------------------------------------------------------------
57 # Repo / commit helpers (shared)
58 # ---------------------------------------------------------------------------
59
60
61 def _init_repo(path: pathlib.Path) -> pathlib.Path:
62 muse = path / ".muse"
63 for sub in ("commits", "snapshots", "objects", "refs/heads"):
64 (muse / sub).mkdir(parents=True, exist_ok=True)
65 (muse / "HEAD").write_text("ref: refs/heads/main\n")
66 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
67 return path
68
69
70 def _write_obj(repo: pathlib.Path, content: bytes) -> str:
71 oid = blob_id(content)
72 write_object(repo, oid, content)
73 return oid
74
75
76 _TS = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
77
78
79 def _commit(
80 repo: pathlib.Path,
81 msg: str,
82 manifest: dict[str, str],
83 *,
84 branch: str = "main",
85 parent: str | None = None,
86 ) -> str:
87 sid = compute_snapshot_id(manifest)
88 write_snapshot(repo, SnapshotRecord(snapshot_id=sid, manifest=manifest, created_at=_TS))
89 parent_ids = [parent] if parent else []
90 cid = compute_commit_id(
91 repo_id="test-repo",
92 parent_ids=parent_ids,
93 snapshot_id=sid,
94 message=msg,
95 committed_at_iso=_TS.isoformat(),
96 author="gabriel",)
97 write_commit(repo, CommitRecord(
98 commit_id=cid, repo_id="test-repo", created_on_branch=branch,
99 snapshot_id=sid, message=msg, committed_at=_TS,
100 author="gabriel", parent_commit_id=parent, parent2_commit_id=None,
101 ))
102 ref = repo / ".muse" / "refs" / "heads" / branch
103 ref.parent.mkdir(parents=True, exist_ok=True)
104 ref.write_text(cid)
105 return cid
106
107
108 def _fp(repo: pathlib.Path, *args: str) -> InvokeResult:
109 return runner.invoke(None, ["format-patch", *args], env={"MUSE_REPO_ROOT": str(repo)})
110
111
112 def _json_out(r: InvokeResult) -> Mapping[str, object]:
113 for line in r.output.splitlines():
114 line = line.strip()
115 if line.startswith("{"):
116 return json.loads(line)
117 raise ValueError(f"No JSON line in output:\n{r.output!r}")
118
119
120 # ---------------------------------------------------------------------------
121 # Register flags [GREEN]
122 # ---------------------------------------------------------------------------
123
124
125 class TestRegisterFlags:
126 """Parser shape — verify flags are wired correctly."""
127
128 def _parse(self, *args: str) -> argparse.Namespace:
129 p = argparse.ArgumentParser()
130 subs = p.add_subparsers()
131 register(subs)
132 return p.parse_args(["format-patch", *args])
133
134 def test_treeish_defaults_to_head(self) -> None:
135 ns = self._parse()
136 assert ns.treeish == "HEAD"
137
138 def test_treeish_positional(self) -> None:
139 ns = self._parse("main")
140 assert ns.treeish == "main"
141
142 def test_output_dir_flag(self) -> None:
143 ns = self._parse("--output-dir", "/tmp")
144 assert ns.output_dir == "/tmp"
145
146 def test_output_dir_short_flag(self) -> None:
147 ns = self._parse("-o", "/tmp")
148 assert ns.output_dir == "/tmp"
149
150 def test_json_flag(self) -> None:
151 ns = self._parse("--json")
152 assert ns.json_out is True
153
154 def test_json_default_false(self) -> None:
155 ns = self._parse()
156 assert ns.json_out is False
157
158 def test_output_dir_default_none(self) -> None:
159 ns = self._parse()
160 assert ns.output_dir is None
161
162 # [RED] — these flags don't exist yet
163 def test_agent_id_flag(self) -> None:
164 ns = self._parse("--agent-id", "claude-code")
165 assert ns.agent_id == "claude-code"
166
167 def test_agent_id_default_empty(self) -> None:
168 ns = self._parse()
169 assert ns.agent_id == ""
170
171 def test_model_id_flag(self) -> None:
172 ns = self._parse("--model-id", "claude-sonnet-4-6")
173 assert ns.model_id == "claude-sonnet-4-6"
174
175 def test_model_id_default_empty(self) -> None:
176 ns = self._parse()
177 assert ns.model_id == ""
178
179 def test_intent_flag(self) -> None:
180 ns = self._parse("--intent", "add login flow")
181 assert ns.intent == "add login flow"
182
183 def test_intent_default_empty(self) -> None:
184 ns = self._parse()
185 assert ns.intent == ""
186
187 def test_no_blobs_flag(self) -> None:
188 ns = self._parse("--no-blobs")
189 assert ns.no_blobs is True
190
191 def test_no_blobs_default_false(self) -> None:
192 ns = self._parse()
193 assert ns.no_blobs is False
194
195
196 # ---------------------------------------------------------------------------
197 # _sem_ver_bump unit tests [GREEN]
198 # ---------------------------------------------------------------------------
199
200
201 class TestSemVerBump:
202 def test_break_prefix_is_major(self) -> None:
203 assert _sem_ver_bump("break: remove old API", [], []) == "major"
204
205 def test_feat_bang_is_major(self) -> None:
206 assert _sem_ver_bump("feat!: overhaul auth", [], []) == "major"
207
208 def test_breaking_change_body_is_major(self) -> None:
209 assert _sem_ver_bump("refactor: cleanup\n\nBREAKING CHANGE: old param removed", [], []) == "major"
210
211 def test_breaking_change_case_insensitive(self) -> None:
212 assert _sem_ver_bump("breaking change in behavior", [], []) == "major"
213
214 def test_feat_prefix_is_minor(self) -> None:
215 assert _sem_ver_bump("feat: add endpoint", [], []) == "minor"
216
217 def test_files_added_is_minor(self) -> None:
218 assert _sem_ver_bump("chore: misc", ["new_file.py"], []) == "minor"
219
220 def test_fix_prefix_is_patch(self) -> None:
221 assert _sem_ver_bump("fix: off-by-one", [], []) == "patch"
222
223 def test_chore_no_additions_is_patch(self) -> None:
224 assert _sem_ver_bump("chore: update deps", [], []) == "patch"
225
226 def test_empty_message_is_patch(self) -> None:
227 assert _sem_ver_bump("", [], []) == "patch"
228
229 def test_files_deleted_alone_is_patch(self) -> None:
230 assert _sem_ver_bump("chore: cleanup", [], ["old.py"]) == "patch"
231
232 def test_feat_prefix_beats_files_added(self) -> None:
233 # Both trigger minor — result is still minor
234 assert _sem_ver_bump("feat: add stuff", ["new.py"], []) == "minor"
235
236 def test_break_prefix_beats_files_added(self) -> None:
237 assert _sem_ver_bump("break: remove", ["new.py"], []) == "major"
238
239 def test_result_is_one_of_three_values(self) -> None:
240 for msg in ["anything", "feat: x", "break: y"]:
241 result = _sem_ver_bump(msg, [], [])
242 assert result in ("major", "minor", "patch")
243
244
245 # ---------------------------------------------------------------------------
246 # _breaking_changes unit tests [GREEN]
247 # ---------------------------------------------------------------------------
248
249
250 class TestBreakingChanges:
251 def test_empty_message_returns_empty(self) -> None:
252 assert _breaking_changes("") == []
253
254 def test_no_breaking_change_returns_empty(self) -> None:
255 assert _breaking_changes("feat: add endpoint") == []
256
257 def test_single_breaking_change(self) -> None:
258 msg = "refactor: cleanup\n\nBREAKING CHANGE: removed --legacy flag"
259 result = _breaking_changes(msg)
260 assert result == ["removed --legacy flag"]
261
262 def test_multiple_breaking_changes(self) -> None:
263 msg = "refactor:\n\nBREAKING CHANGE: first\nBREAKING CHANGE: second"
264 result = _breaking_changes(msg)
265 assert result == ["first", "second"]
266
267 def test_leading_trailing_whitespace_stripped(self) -> None:
268 msg = "BREAKING CHANGE: trimmed "
269 result = _breaking_changes(msg)
270 assert result == ["trimmed"]
271
272 def test_not_at_start_of_line_ignored(self) -> None:
273 # Mid-sentence "BREAKING CHANGE" not at start of line
274 msg = "This has BREAKING CHANGE: in the middle"
275 # The implementation checks stripped.upper().startswith("BREAKING CHANGE:")
276 # so it WOULD match if the stripped line starts with it — it does here
277 # because "This has..." stripped starts with "This" not "BREAKING CHANGE"
278 result = _breaking_changes(msg)
279 assert result == []
280
281 def test_returns_list(self) -> None:
282 assert isinstance(_breaking_changes("anything"), list)
283
284
285 # ---------------------------------------------------------------------------
286 # _make_patch_filename unit tests [GREEN]
287 # ---------------------------------------------------------------------------
288
289
290 class TestMakePatchFilename:
291 def test_basic_subject(self) -> None:
292 assert _make_patch_filename("feat: add hello") == "feat-add-hello.mpatch"
293
294 def test_ends_with_mpatch(self) -> None:
295 name = _make_patch_filename("anything")
296 assert name.endswith(".mpatch")
297
298 def test_empty_subject_returns_patch(self) -> None:
299 assert _make_patch_filename("") == "patch.mpatch"
300
301 def test_slash_replaced(self) -> None:
302 name = _make_patch_filename("fix/my-bug")
303 assert "/" not in name
304
305 def test_backslash_replaced(self) -> None:
306 name = _make_patch_filename("fix\\my-bug")
307 assert "\\" not in name
308
309 def test_dot_replaced(self) -> None:
310 # dots → dashes in the slug portion (before the .mpatch extension)
311 name = _make_patch_filename("v1.2.3 release")
312 slug = name.removesuffix(".mpatch")
313 assert "." not in slug
314
315 def test_long_subject_truncated(self) -> None:
316 long_msg = "x" * 100
317 name = _make_patch_filename(long_msg)
318 slug = name.removesuffix(".mpatch")
319 assert len(slug) <= 52
320
321 def test_unicode_stripped(self) -> None:
322 name = _make_patch_filename("feat: émoji 🚀 add")
323 # Non-ASCII removed, but ASCII words remain
324 assert "feat" in name
325
326 def test_whitespace_replaced_with_dash(self) -> None:
327 name = _make_patch_filename("add multiple spaces")
328 assert " " not in name
329
330 def test_no_leading_trailing_dashes_in_slug(self) -> None:
331 slug = _make_patch_filename(" spaces around ").removesuffix(".mpatch")
332 assert not slug.startswith("-")
333 assert not slug.endswith("-")
334
335 def test_no_consecutive_dashes_in_slug(self) -> None:
336 slug = _make_patch_filename("a!!b").removesuffix(".mpatch")
337 assert "--" not in slug
338
339
340 # ---------------------------------------------------------------------------
341 # _action_label unit tests [GREEN]
342 # ---------------------------------------------------------------------------
343
344
345 class TestActionLabel:
346 def test_insert_is_inserted(self) -> None:
347 assert _action_label("insert") == "inserted"
348
349 def test_delete_is_deleted(self) -> None:
350 assert _action_label("delete") == "deleted"
351
352 def test_replace_is_modified(self) -> None:
353 assert _action_label("replace") == "modified"
354
355 def test_mutate_is_modified(self) -> None:
356 assert _action_label("mutate") == "modified"
357
358 def test_patch_is_modified(self) -> None:
359 assert _action_label("patch") == "modified"
360
361 def test_move_is_moved(self) -> None:
362 assert _action_label("move") == "moved"
363
364 def test_directory_rename_is_renamed(self) -> None:
365 assert _action_label("directory_rename") == "renamed"
366
367 def test_unknown_defaults_to_modified(self) -> None:
368 assert _action_label("frob") == "modified"
369 assert _action_label("") == "modified"
370 assert _action_label("UPDATE") == "modified"
371
372
373 # ---------------------------------------------------------------------------
374 # _build_file_level_ops — internal unit tests [GREEN + RED for rename]
375 # ---------------------------------------------------------------------------
376
377
378 class TestBuildFileOps:
379 def test_added_file_in_ops(self) -> None:
380 base: dict[str, str] = {}
381 target = {"new.py": long_id("a" * 64)}
382 ops, added, modified, deleted, *_ = _build_file_level_ops(base, target)
383 assert any(op["address"] == "new.py" and op["op"] == "insert" for op in ops)
384
385 def test_deleted_file_in_ops(self) -> None:
386 base = {"old.py": long_id("a" * 64)}
387 target: dict[str, str] = {}
388 ops, added, modified, deleted, *_ = _build_file_level_ops(base, target)
389 assert any(op["address"] == "old.py" and op["op"] == "delete" for op in ops)
390
391 def test_modified_file_in_ops(self) -> None:
392 oid_a = long_id("a" * 64)
393 oid_b = long_id("b" * 64)
394 ops, added, modified, deleted, *_ = _build_file_level_ops(
395 {"f.py": oid_a}, {"f.py": oid_b}
396 )
397 assert any(op["address"] == "f.py" and op["op"] == "replace" for op in ops)
398
399 def test_added_list_sorted(self) -> None:
400 base: dict[str, str] = {}
401 target = {"z.py": long_id("z" * 64), "a.py": long_id("a" * 64)}
402 _, added, _, _, *_ = _build_file_level_ops(base, target)
403 assert added == sorted(added)
404
405 def test_deleted_list_sorted(self) -> None:
406 base = {"z.py": long_id("z" * 64), "a.py": long_id("a" * 64)}
407 _, _, _, deleted, *_ = _build_file_level_ops(base, {})
408 assert deleted == sorted(deleted)
409
410 def test_modified_list_sorted(self) -> None:
411 oid_a = long_id("a" * 64)
412 oid_b = long_id("b" * 64)
413 base = {"z.py": oid_a, "a.py": oid_a}
414 target = {"z.py": oid_b, "a.py": oid_b}
415 _, _, modified, _, *_ = _build_file_level_ops(base, target)
416 assert modified == sorted(modified)
417
418 def test_unchanged_file_not_in_ops(self) -> None:
419 oid = long_id("a" * 64)
420 ops, _, _, _, *_ = _build_file_level_ops({"f.py": oid}, {"f.py": oid})
421 addresses = [op["address"] for op in ops]
422 assert "f.py" not in addresses
423
424 # [RED] rename detection — same oid deleted + added at different path = rename
425 def test_rename_detected(self) -> None:
426 oid = long_id("a" * 64)
427 base = {"old.py": oid}
428 target = {"new.py": oid}
429 ops, added, modified, deleted, renamed = _build_file_level_ops(base, target)
430 assert "old.py" in renamed
431 assert renamed["old.py"] == "new.py"
432
433 def test_rename_not_in_files_added(self) -> None:
434 oid = long_id("a" * 64)
435 _, added, _, _, renamed = _build_file_level_ops({"old.py": oid}, {"new.py": oid})
436 assert "new.py" not in added
437
438 def test_rename_not_in_files_deleted(self) -> None:
439 oid = long_id("a" * 64)
440 _, _, _, deleted, renamed = _build_file_level_ops({"old.py": oid}, {"new.py": oid})
441 assert "old.py" not in deleted
442
443 def test_rename_op_present_in_ops(self) -> None:
444 oid = long_id("a" * 64)
445 ops, _, _, _, _ = _build_file_level_ops({"old.py": oid}, {"new.py": oid})
446 rename_ops = [op for op in ops if op.get("op") == "move"]
447 assert len(rename_ops) == 1
448
449 def test_rename_op_action_label_is_moved(self) -> None:
450 oid = long_id("a" * 64)
451 ops, _, _, _, _ = _build_file_level_ops({"old.py": oid}, {"new.py": oid})
452 rename_ops = [op for op in ops if op.get("op") == "move"]
453 assert rename_ops[0]["action_label"] == "moved"
454
455 def test_different_oid_not_a_rename(self) -> None:
456 oid_a = long_id("a" * 64)
457 oid_b = long_id("b" * 64)
458 _, added, _, deleted, renamed = _build_file_level_ops(
459 {"old.py": oid_a}, {"new.py": oid_b}
460 )
461 assert not renamed
462 assert "old.py" in deleted
463 assert "new.py" in added
464
465 def test_empty_renamed_dict_when_no_renames(self) -> None:
466 oid_a = long_id("a" * 64)
467 oid_b = long_id("b" * 64)
468 _, _, _, _, renamed = _build_file_level_ops({"f.py": oid_a}, {"f.py": oid_b})
469 assert renamed == {}
470
471
472 # ---------------------------------------------------------------------------
473 # Blob embedding [GREEN]
474 # ---------------------------------------------------------------------------
475
476
477 class TestBlobEmbedding:
478 def test_blobs_field_present(self, tmp_path: pathlib.Path) -> None:
479 repo = _init_repo(tmp_path)
480 oid = _write_obj(repo, b"hello blob")
481 _commit(repo, "init", {"f.py": oid})
482 data = _json_out(_fp(repo, "--json"))
483 assert "blobs" in data
484
485 def test_blobs_is_dict(self, tmp_path: pathlib.Path) -> None:
486 repo = _init_repo(tmp_path)
487 oid = _write_obj(repo, b"hello blob")
488 _commit(repo, "init", {"f.py": oid})
489 data = _json_out(_fp(repo, "--json"))
490 assert isinstance(data["blobs"], dict)
491
492 def test_blob_key_matches_required_object(self, tmp_path: pathlib.Path) -> None:
493 repo = _init_repo(tmp_path)
494 content = b"blob content"
495 oid = _write_obj(repo, content)
496 _commit(repo, "init", {"f.py": oid})
497 data = _json_out(_fp(repo, "--json"))
498 assert oid in data["blobs"]
499
500 def test_blob_decodes_to_original_content(self, tmp_path: pathlib.Path) -> None:
501 import base64
502 repo = _init_repo(tmp_path)
503 content = b"exact bytes\x00\x01\x02"
504 oid = _write_obj(repo, content)
505 _commit(repo, "init", {"f.py": oid})
506 data = _json_out(_fp(repo, "--json"))
507 decoded = base64.b64decode(data["blobs"][oid])
508 assert decoded == content
509
510 def test_blobs_is_base64_valid_string(self, tmp_path: pathlib.Path) -> None:
511 import base64
512 repo = _init_repo(tmp_path)
513 oid = _write_obj(repo, b"any content")
514 _commit(repo, "init", {"f.py": oid})
515 data = _json_out(_fp(repo, "--json"))
516 for val in data["blobs"].values():
517 assert isinstance(val, str)
518 base64.b64decode(val) # must not raise
519
520 def test_unmodified_objects_not_in_blobs(self, tmp_path: pathlib.Path) -> None:
521 """Blobs only contains objects in to_manifest (new/modified), not deleted."""
522 repo = _init_repo(tmp_path)
523 oid_a = _write_obj(repo, b"a")
524 oid_b = _write_obj(repo, b"b")
525 c1 = _commit(repo, "c1", {"a.py": oid_a, "b.py": oid_b})
526 oid_c = _write_obj(repo, b"c")
527 _commit(repo, "c2", {"a.py": oid_a, "c.py": oid_c}, parent=c1)
528 # b.py deleted → oid_b not in to_manifest → not in blobs
529 data = _json_out(_fp(repo, "--json"))
530 assert oid_b not in data["blobs"]
531
532
533 # ---------------------------------------------------------------------------
534 # Required objects [GREEN]
535 # ---------------------------------------------------------------------------
536
537
538 class TestRequiredObjects:
539 def test_all_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
540 repo = _init_repo(tmp_path)
541 oid = _write_obj(repo, b"x")
542 _commit(repo, "init", {"f.py": oid})
543 data = _json_out(_fp(repo, "--json"))
544 for rid in data["required_objects"]:
545 assert rid.startswith("sha256:")
546
547 def test_required_objects_is_sorted(self, tmp_path: pathlib.Path) -> None:
548 repo = _init_repo(tmp_path)
549 oid_a = _write_obj(repo, b"aaa")
550 oid_b = _write_obj(repo, b"bbb")
551 _commit(repo, "init", {"a.py": oid_a, "b.py": oid_b})
552 data = _json_out(_fp(repo, "--json"))
553 ro = data["required_objects"]
554 assert ro == sorted(ro)
555
556 def test_required_objects_subset_of_to_manifest(self, tmp_path: pathlib.Path) -> None:
557 repo = _init_repo(tmp_path)
558 oid = _write_obj(repo, b"y")
559 _commit(repo, "init", {"f.py": oid})
560 data = _json_out(_fp(repo, "--json"))
561 to_vals = set(data["to_manifest"].values())
562 for rid in data["required_objects"]:
563 assert rid in to_vals
564
565 def test_required_objects_empty_for_no_change(self, tmp_path: pathlib.Path) -> None:
566 repo = _init_repo(tmp_path)
567 oid = _write_obj(repo, b"z")
568 c1 = _commit(repo, "c1", {"f.py": oid})
569 _commit(repo, "c2 no-op", {"f.py": oid}, parent=c1)
570 data = _json_out(_fp(repo, "--json"))
571 assert data["required_objects"] == []
572
573
574 # ---------------------------------------------------------------------------
575 # Manifest delta correctness [GREEN]
576 # ---------------------------------------------------------------------------
577
578
579 class TestManifestDelta:
580 def test_added_path_in_to_manifest(self, tmp_path: pathlib.Path) -> None:
581 repo = _init_repo(tmp_path)
582 oid = _write_obj(repo, b"new")
583 _commit(repo, "init", {"new.py": oid})
584 data = _json_out(_fp(repo, "--json"))
585 assert "new.py" in data["to_manifest"]
586
587 def test_added_path_not_in_from_manifest(self, tmp_path: pathlib.Path) -> None:
588 repo = _init_repo(tmp_path)
589 oid = _write_obj(repo, b"new")
590 _commit(repo, "init", {"new.py": oid})
591 data = _json_out(_fp(repo, "--json"))
592 assert "new.py" not in data["from_manifest"]
593
594 def test_deleted_path_in_from_manifest(self, tmp_path: pathlib.Path) -> None:
595 repo = _init_repo(tmp_path)
596 oid = _write_obj(repo, b"old")
597 c1 = _commit(repo, "c1", {"old.py": oid})
598 _commit(repo, "c2", {}, parent=c1)
599 data = _json_out(_fp(repo, "--json"))
600 assert "old.py" in data["from_manifest"]
601
602 def test_deleted_path_not_in_to_manifest(self, tmp_path: pathlib.Path) -> None:
603 repo = _init_repo(tmp_path)
604 oid = _write_obj(repo, b"old")
605 c1 = _commit(repo, "c1", {"old.py": oid})
606 _commit(repo, "c2", {}, parent=c1)
607 data = _json_out(_fp(repo, "--json"))
608 assert "old.py" not in data["to_manifest"]
609
610 def test_modified_path_in_both_manifests(self, tmp_path: pathlib.Path) -> None:
611 repo = _init_repo(tmp_path)
612 oid_a = _write_obj(repo, b"v1")
613 c1 = _commit(repo, "c1", {"f.py": oid_a})
614 oid_b = _write_obj(repo, b"v2")
615 _commit(repo, "c2", {"f.py": oid_b}, parent=c1)
616 data = _json_out(_fp(repo, "--json"))
617 assert "f.py" in data["from_manifest"]
618 assert "f.py" in data["to_manifest"]
619 assert data["from_manifest"]["f.py"] != data["to_manifest"]["f.py"]
620
621 def test_unchanged_path_not_in_either_manifest(self, tmp_path: pathlib.Path) -> None:
622 repo = _init_repo(tmp_path)
623 oid_keep = _write_obj(repo, b"keep")
624 oid_chg = _write_obj(repo, b"v1")
625 c1 = _commit(repo, "c1", {"keep.py": oid_keep, "chg.py": oid_chg})
626 oid_chg2 = _write_obj(repo, b"v2")
627 _commit(repo, "c2", {"keep.py": oid_keep, "chg.py": oid_chg2}, parent=c1)
628 data = _json_out(_fp(repo, "--json"))
629 assert "keep.py" not in data["from_manifest"]
630 assert "keep.py" not in data["to_manifest"]
631
632
633 # ---------------------------------------------------------------------------
634 # Initial commit sentinel [GREEN]
635 # ---------------------------------------------------------------------------
636
637
638 class TestInitialCommit:
639 def test_from_snapshot_id_is_sentinel_for_initial(self, tmp_path: pathlib.Path) -> None:
640 repo = _init_repo(tmp_path)
641 oid = _write_obj(repo, b"x")
642 _commit(repo, "init", {"f.py": oid})
643 data = _json_out(_fp(repo, "--json"))
644 # Sentinel for initial commit is sha256:000...000 (64 zeros)
645 assert data["from_snapshot_id"] == long_id("0" * 64)
646
647 def test_from_commit_id_empty_for_initial(self, tmp_path: pathlib.Path) -> None:
648 repo = _init_repo(tmp_path)
649 oid = _write_obj(repo, b"x")
650 _commit(repo, "init", {"f.py": oid})
651 data = _json_out(_fp(repo, "--json"))
652 assert data["from_commit_id"] == ""
653
654 def test_all_files_in_files_added_for_initial(self, tmp_path: pathlib.Path) -> None:
655 repo = _init_repo(tmp_path)
656 oid_a = _write_obj(repo, b"a")
657 oid_b = _write_obj(repo, b"b")
658 _commit(repo, "init", {"a.py": oid_a, "b.py": oid_b})
659 data = _json_out(_fp(repo, "--json"))
660 assert "a.py" in data["files_added"]
661 assert "b.py" in data["files_added"]
662 assert data["files_modified"] == []
663 assert data["files_deleted"] == []
664
665 def test_from_snapshot_id_set_for_second_commit(self, tmp_path: pathlib.Path) -> None:
666 repo = _init_repo(tmp_path)
667 oid = _write_obj(repo, b"v1")
668 c1 = _commit(repo, "c1", {"f.py": oid})
669 oid2 = _write_obj(repo, b"v2")
670 _commit(repo, "c2", {"f.py": oid2}, parent=c1)
671 data = _json_out(_fp(repo, "--json"))
672 # Non-initial: from_snapshot_id should NOT be the sentinel
673 assert data["from_snapshot_id"] != long_id("0" * 64)
674
675
676 # ---------------------------------------------------------------------------
677 # Agent provenance flags [RED] — --agent-id, --model-id, --intent
678 # ---------------------------------------------------------------------------
679
680
681 class TestAgentProvenance:
682 def test_agent_id_set_in_output(self, tmp_path: pathlib.Path) -> None:
683 repo = _init_repo(tmp_path)
684 oid = _write_obj(repo, b"x")
685 _commit(repo, "init", {"f.py": oid})
686 data = _json_out(_fp(repo, "--json", "--agent-id", "claude-code"))
687 assert data["agent_id"] == "claude-code"
688
689 def test_model_id_set_in_output(self, tmp_path: pathlib.Path) -> None:
690 repo = _init_repo(tmp_path)
691 oid = _write_obj(repo, b"x")
692 _commit(repo, "init", {"f.py": oid})
693 data = _json_out(_fp(repo, "--json", "--model-id", "claude-sonnet-4-6"))
694 assert data["model_id"] == "claude-sonnet-4-6"
695
696 def test_intent_set_in_output(self, tmp_path: pathlib.Path) -> None:
697 repo = _init_repo(tmp_path)
698 oid = _write_obj(repo, b"x")
699 _commit(repo, "init", {"f.py": oid})
700 data = _json_out(_fp(repo, "--json", "--intent", "bootstrap project"))
701 assert data["intent"] == "bootstrap project"
702
703 def test_agent_id_in_mpatch_file(self, tmp_path: pathlib.Path) -> None:
704 repo = _init_repo(tmp_path)
705 oid = _write_obj(repo, b"x")
706 _commit(repo, "init", {"f.py": oid})
707 out_dir = tmp_path / "patches"
708 out_dir.mkdir()
709 r = _fp(repo, "--output-dir", str(out_dir), "--agent-id", "claude-code")
710 assert r.exit_code == 0
711 patch_file = list(out_dir.glob("*.mpatch"))[0]
712 data = json.loads(patch_file.read_bytes())
713 assert data["agent_id"] == "claude-code"
714
715 def test_agent_id_affects_patch_id(self, tmp_path: pathlib.Path) -> None:
716 """Different agent_id → different patch_id (agent_id is part of canonical JSON)."""
717 repo = _init_repo(tmp_path)
718 oid = _write_obj(repo, b"x")
719 _commit(repo, "init", {"f.py": oid})
720 pid_no_agent = _json_out(_fp(repo, "--json"))["patch_id"]
721 pid_with_agent = _json_out(_fp(repo, "--json", "--agent-id", "claude-code"))["patch_id"]
722 assert pid_no_agent != pid_with_agent
723
724 def test_no_agent_flags_leaves_fields_empty(self, tmp_path: pathlib.Path) -> None:
725 repo = _init_repo(tmp_path)
726 oid = _write_obj(repo, b"x")
727 _commit(repo, "init", {"f.py": oid})
728 data = _json_out(_fp(repo, "--json"))
729 assert data["agent_id"] == ""
730 assert data["model_id"] == ""
731 assert data["intent"] == ""
732
733 def test_all_provenance_flags_together(self, tmp_path: pathlib.Path) -> None:
734 repo = _init_repo(tmp_path)
735 oid = _write_obj(repo, b"x")
736 _commit(repo, "init", {"f.py": oid})
737 data = _json_out(_fp(repo, "--json",
738 "--agent-id", "claude-code",
739 "--model-id", "claude-sonnet-4-6",
740 "--intent", "add login endpoint"))
741 assert data["agent_id"] == "claude-code"
742 assert data["model_id"] == "claude-sonnet-4-6"
743 assert data["intent"] == "add login endpoint"
744
745
746 # ---------------------------------------------------------------------------
747 # --no-blobs flag [RED]
748 # ---------------------------------------------------------------------------
749
750
751 class TestNoBlobs:
752 def test_no_blobs_empties_blobs_dict(self, tmp_path: pathlib.Path) -> None:
753 repo = _init_repo(tmp_path)
754 oid = _write_obj(repo, b"blob content here")
755 _commit(repo, "init", {"f.py": oid})
756 data = _json_out(_fp(repo, "--json", "--no-blobs"))
757 assert data["blobs"] == {}
758
759 def test_no_blobs_preserves_required_objects(self, tmp_path: pathlib.Path) -> None:
760 """required_objects still lists what the target needs even without inline blobs."""
761 repo = _init_repo(tmp_path)
762 oid = _write_obj(repo, b"blob content here")
763 _commit(repo, "init", {"f.py": oid})
764 data = _json_out(_fp(repo, "--json", "--no-blobs"))
765 assert oid in data["required_objects"]
766
767 def test_no_blobs_in_mpatch_file(self, tmp_path: pathlib.Path) -> None:
768 repo = _init_repo(tmp_path)
769 oid = _write_obj(repo, b"some bytes")
770 _commit(repo, "init", {"f.py": oid})
771 out_dir = tmp_path / "patches"
772 out_dir.mkdir()
773 r = _fp(repo, "--output-dir", str(out_dir), "--no-blobs")
774 assert r.exit_code == 0
775 data = json.loads(list(out_dir.glob("*.mpatch"))[0].read_bytes())
776 assert data["blobs"] == {}
777
778 def test_default_has_blobs(self, tmp_path: pathlib.Path) -> None:
779 """Without --no-blobs, blobs are embedded (existing behavior)."""
780 repo = _init_repo(tmp_path)
781 oid = _write_obj(repo, b"keep me")
782 _commit(repo, "init", {"f.py": oid})
783 data = _json_out(_fp(repo, "--json"))
784 assert len(data["blobs"]) > 0
785
786 def test_no_blobs_output_smaller_than_with_blobs(self, tmp_path: pathlib.Path) -> None:
787 """--no-blobs patch should be smaller (no base64 content)."""
788 repo = _init_repo(tmp_path)
789 content = b"x" * 1024 # 1KB object
790 oid = _write_obj(repo, content)
791 _commit(repo, "init", {"f.py": oid})
792 r_with = _fp(repo, "--json")
793 r_no = _fp(repo, "--json", "--no-blobs")
794 assert len(r_no.output) < len(r_with.output)
795
796
797 # ---------------------------------------------------------------------------
798 # Rename detection via CLI [RED]
799 # ---------------------------------------------------------------------------
800
801
802 class TestRenameDetectionCLI:
803 def test_rename_in_files_renamed(self, tmp_path: pathlib.Path) -> None:
804 repo = _init_repo(tmp_path)
805 oid = _write_obj(repo, b"shared content")
806 c1 = _commit(repo, "c1", {"old.py": oid})
807 _commit(repo, "c2 rename", {"new.py": oid}, parent=c1)
808 data = _json_out(_fp(repo, "--json"))
809 assert "old.py" in data["files_renamed"]
810 assert data["files_renamed"]["old.py"] == "new.py"
811
812 def test_rename_not_in_files_added(self, tmp_path: pathlib.Path) -> None:
813 repo = _init_repo(tmp_path)
814 oid = _write_obj(repo, b"shared content")
815 c1 = _commit(repo, "c1", {"old.py": oid})
816 _commit(repo, "c2 rename", {"new.py": oid}, parent=c1)
817 data = _json_out(_fp(repo, "--json"))
818 assert "new.py" not in data["files_added"]
819
820 def test_rename_not_in_files_deleted(self, tmp_path: pathlib.Path) -> None:
821 repo = _init_repo(tmp_path)
822 oid = _write_obj(repo, b"shared content")
823 c1 = _commit(repo, "c1", {"old.py": oid})
824 _commit(repo, "c2 rename", {"new.py": oid}, parent=c1)
825 data = _json_out(_fp(repo, "--json"))
826 assert "old.py" not in data["files_deleted"]
827
828 def test_genuine_add_and_delete_not_confused_for_rename(self, tmp_path: pathlib.Path) -> None:
829 repo = _init_repo(tmp_path)
830 oid_a = _write_obj(repo, b"content A")
831 oid_b = _write_obj(repo, b"content B")
832 c1 = _commit(repo, "c1", {"a.py": oid_a})
833 _commit(repo, "c2", {"b.py": oid_b}, parent=c1)
834 data = _json_out(_fp(repo, "--json"))
835 assert data["files_renamed"] == {}
836 assert "b.py" in data["files_added"]
837 assert "a.py" in data["files_deleted"]
838
839
840 # ---------------------------------------------------------------------------
841 # Default stdout output [GREEN]
842 # ---------------------------------------------------------------------------
843
844
845 class TestDefaultOutput:
846 def test_default_output_is_valid_json(self, tmp_path: pathlib.Path) -> None:
847 repo = _init_repo(tmp_path)
848 oid = _write_obj(repo, b"x")
849 _commit(repo, "init", {"f.py": oid})
850 r = _fp(repo)
851 assert r.exit_code == 0
852 data = json.loads(r.output.strip())
853 assert "patch_id" in data
854
855 def test_default_output_has_patch_id(self, tmp_path: pathlib.Path) -> None:
856 repo = _init_repo(tmp_path)
857 oid = _write_obj(repo, b"x")
858 _commit(repo, "init", {"f.py": oid})
859 r = _fp(repo)
860 data = json.loads(r.output.strip())
861 assert data["patch_id"].startswith("sha256:")
862
863
864 # ---------------------------------------------------------------------------
865 # ops completeness [GREEN]
866 # ---------------------------------------------------------------------------
867
868
869 class TestOpsCompleteness:
870 def test_ops_count_equals_sum_of_file_lists(self, tmp_path: pathlib.Path) -> None:
871 repo = _init_repo(tmp_path)
872 oid1 = _write_obj(repo, b"a")
873 oid2 = _write_obj(repo, b"b")
874 oid3 = _write_obj(repo, b"c")
875 c1 = _commit(repo, "c1", {"a.py": oid1, "b.py": oid2, "c.py": oid3})
876 oid4 = _write_obj(repo, b"a-modified")
877 _commit(repo, "c2", {"a.py": oid4, "b.py": oid2}, parent=c1)
878 data = _json_out(_fp(repo, "--json"))
879 # c.py deleted, a.py modified, b.py unchanged
880 total_file_changes = (
881 len(data["files_added"])
882 + len(data["files_modified"])
883 + len(data["files_deleted"])
884 + len(data["files_renamed"])
885 )
886 # Each changed file has exactly one op (excluding renames which have one move op)
887 assert len(data["ops"]) == total_file_changes
888
889 def test_each_op_has_required_fields(self, tmp_path: pathlib.Path) -> None:
890 repo = _init_repo(tmp_path)
891 oid_a = _write_obj(repo, b"a")
892 oid_b = _write_obj(repo, b"b")
893 c1 = _commit(repo, "c1", {"a.py": oid_a})
894 oid_c = _write_obj(repo, b"a-mod")
895 _commit(repo, "c2", {"a.py": oid_c, "b.py": oid_b}, parent=c1)
896 data = _json_out(_fp(repo, "--json"))
897 for op in data["ops"]:
898 assert "op" in op
899 assert "address" in op
900 assert "action_label" in op
901
902
903 # ---------------------------------------------------------------------------
904 # Stress [GREEN]
905 # ---------------------------------------------------------------------------
906
907
908 class TestStress:
909 def test_50_files_added(self, tmp_path: pathlib.Path) -> None:
910 repo = _init_repo(tmp_path)
911 manifest = {}
912 for i in range(50):
913 content = f"# file {i}\n".encode() * 10
914 oid = _write_obj(repo, content)
915 manifest[f"src/file_{i:02d}.py"] = oid
916 _commit(repo, "feat: add 50 files", manifest)
917 r = _fp(repo, "--json")
918 assert r.exit_code == 0
919 data = _json_out(r)
920 assert len(data["files_added"]) == 50
921 assert len(data["ops"]) == 50
922
923 def test_mixed_50_file_commit(self, tmp_path: pathlib.Path) -> None:
924 repo = _init_repo(tmp_path)
925 manifest_c1 = {}
926 for i in range(40):
927 oid = _write_obj(repo, f"v1-{i}".encode())
928 manifest_c1[f"f{i:02d}.py"] = oid
929 c1 = _commit(repo, "c1", manifest_c1)
930
931 manifest_c2 = {}
932 # Keep 20, modify 10, delete 10, add 10 new
933 oids = list(manifest_c1.items())
934 for path, oid in oids[:20]:
935 manifest_c2[path] = oid
936 for path, _ in oids[20:30]:
937 manifest_c2[path] = _write_obj(repo, f"v2-{path}".encode())
938 # oids[30:40] deleted
939 for i in range(10):
940 manifest_c2[f"new{i}.py"] = _write_obj(repo, f"new-{i}".encode())
941 _commit(repo, "c2 mixed", manifest_c2, parent=c1)
942
943 r = _fp(repo, "--json")
944 assert r.exit_code == 0
945 data = _json_out(r)
946 assert len(data["files_added"]) == 10
947 assert len(data["files_modified"]) == 10
948 assert len(data["files_deleted"]) == 10
949
950
951 # ---------------------------------------------------------------------------
952 # Security [GREEN]
953 # ---------------------------------------------------------------------------
954
955
956 class TestSecurity:
957 def test_path_traversal_in_treeish_rejected(self, tmp_path: pathlib.Path) -> None:
958 repo = _init_repo(tmp_path)
959 oid = _write_obj(repo, b"x")
960 _commit(repo, "init", {"f.py": oid})
961 r = _fp(repo, "../../etc/passwd", "--json")
962 assert r.exit_code != 0
963
964 def test_ansi_escape_in_treeish_rejected(self, tmp_path: pathlib.Path) -> None:
965 repo = _init_repo(tmp_path)
966 oid = _write_obj(repo, b"x")
967 _commit(repo, "init", {"f.py": oid})
968 r = _fp(repo, "\x1b[31mbad\x1b[0m", "--json")
969 assert r.exit_code != 0
970
971 def test_very_long_treeish_rejected(self, tmp_path: pathlib.Path) -> None:
972 repo = _init_repo(tmp_path)
973 oid = _write_obj(repo, b"x")
974 _commit(repo, "init", {"f.py": oid})
975 r = _fp(repo, "a" * 300, "--json")
976 assert r.exit_code != 0
977
978 def test_null_byte_in_treeish_rejected(self, tmp_path: pathlib.Path) -> None:
979 repo = _init_repo(tmp_path)
980 oid = _write_obj(repo, b"x")
981 _commit(repo, "init", {"f.py": oid})
982 r = _fp(repo, "main\x00evil", "--json")
983 assert r.exit_code != 0
984
985 def test_error_goes_to_stderr_not_stdout(self, tmp_path: pathlib.Path) -> None:
986 repo = _init_repo(tmp_path)
987 r = _fp(repo, "--json") # empty repo → error
988 assert r.exit_code != 0
989 assert "❌" in r.stderr or "error" in r.stderr.lower() or r.exit_code != 0
990
991 def test_no_traceback_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
992 repo = _init_repo(tmp_path)
993 oid = _write_obj(repo, b"x")
994 _commit(repo, "init", {"f.py": oid})
995 r = _fp(repo, "no-such-ref", "--json")
996 assert "Traceback" not in r.output
997 assert "Traceback" not in r.stderr
998
999
1000 # ---------------------------------------------------------------------------
1001 # Performance [GREEN]
1002 # ---------------------------------------------------------------------------
1003
1004
1005 class TestPerformance:
1006 def test_duration_ms_under_two_seconds(self, tmp_path: pathlib.Path) -> None:
1007 repo = _init_repo(tmp_path)
1008 manifest = {}
1009 for i in range(20):
1010 oid = _write_obj(repo, f"content {i}".encode() * 50)
1011 manifest[f"file_{i}.py"] = oid
1012 _commit(repo, "feat: 20 files", manifest)
1013 data = _json_out(_fp(repo, "--json"))
1014 assert data["duration_ms"] < 2000.0
1015
1016 def test_duration_ms_non_negative(self, tmp_path: pathlib.Path) -> None:
1017 repo = _init_repo(tmp_path)
1018 oid = _write_obj(repo, b"x")
1019 _commit(repo, "init", {"f.py": oid})
1020 data = _json_out(_fp(repo, "--json"))
1021 assert data["duration_ms"] >= 0.0
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