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