scripts/test_review_state.py
scripts/test_review_state.pyBrowse 10 files
7,572 tokens
34,112 bytes
Token encoding: o200k_base
Snapshot 506f736
← Back to SKILL.md
1#!/usr/bin/env python32 3from __future__ import annotations4 5import hashlib6import json7import os8import subprocess9import sys10import tempfile11import unittest12from pathlib import Path13from unittest import mock14 15sys.path.insert(0, str(Path(__file__).parent))16 17import review_state as review_state_module18from review_state import (19 _component,20 _content_fingerprint,21 _load_pathspec_file,22 _workspace_entry,23 review_state,24)25 26 27class ReviewStateTest(unittest.TestCase):28 def setUp(self) -> None:29 self.temporary_directory = tempfile.TemporaryDirectory()30 self.root = Path(self.temporary_directory.name)31 self.repo = self.root / "repo"32 self.repo.mkdir()33 self._git("init", "-q")34 self._git("config", "user.email", "review-state@example.test")35 self._git("config", "user.name", "Review State Test")36 (self.repo / ".gitignore").write_text("plans/private.md\n")37 (self.repo / "src").mkdir()38 (self.repo / "tests").mkdir()39 (self.repo / "plans").mkdir()40 (self.repo / "src" / "runtime.py").write_text("VALUE = 1\n")41 (self.repo / "tests" / "test_runtime.py").write_text("assert True\n")42 self._git("add", ".")43 self._git("commit", "-qm", "initial")44 self.base = self._git("rev-parse", "HEAD").strip()45 46 def tearDown(self) -> None:47 self.temporary_directory.cleanup()48 49 def _git(self, *args: str) -> str:50 return subprocess.check_output(("git", "-C", str(self.repo), *args), text=True)51 52 def _run_cli(self, *args: str) -> subprocess.CompletedProcess[str]:53 return subprocess.run(54 (55 sys.executable,56 str(Path(__file__).with_name("review_state.py")),57 "--repo",58 str(self.repo),59 "--base",60 self.base,61 *args,62 ),63 capture_output=True,64 text=True,65 )66 67 def test_equivalent_pathspecs_have_the_same_content_fingerprint(self) -> None:68 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")69 explicit = review_state(self.repo, self.base, ("src/runtime.py",))70 directory = review_state(self.repo, self.base, ("src",))71 with_ignored_artifact = review_state(72 self.repo, self.base, ("src/runtime.py", "plans/private.md")73 )74 75 self.assertEqual(76 explicit["content_fingerprint"], directory["content_fingerprint"]77 )78 self.assertEqual(79 explicit["content_fingerprint"],80 with_ignored_artifact["content_fingerprint"],81 )82 83 def test_repository_path_must_be_the_worktree_root(self) -> None:84 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")85 86 with self.assertRaisesRegex(ValueError, "worktree root"):87 review_state(self.repo / "src", self.base, ("src/runtime.py",))88 89 def test_repository_changes_during_snapshot_fail_closed(self) -> None:90 """Reject a diff and workspace fingerprint captured from different states."""91 runtime = self.repo / "src" / "runtime.py"92 runtime.write_text("VALUE = 2\n")93 original_complete_diff = review_state_module._complete_diff94 mutated = False95 96 def complete_diff_then_mutate(repo: Path, base: str, pathspecs: tuple[str, ...]) -> bytes:97 nonlocal mutated98 result = original_complete_diff(repo, base, pathspecs)99 if not mutated:100 runtime.write_text("VALUE = 3\n")101 mutated = True102 return result103 104 with (105 mock.patch.object(106 review_state_module,107 "_complete_diff",108 side_effect=complete_diff_then_mutate,109 ),110 self.assertRaisesRegex(ValueError, "changed while review state was captured"),111 ):112 review_state(self.repo, self.base, ("src/runtime.py",))113 114 @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.")115 def test_exact_special_file_path_fails_closed(self) -> None:116 """Reject a task manifest entry that cannot produce a finite diff."""117 fifo = self.repo / "artifact.pipe"118 os.mkfifo(fifo)119 120 with self.assertRaisesRegex(ValueError, "Unsupported workspace file type"):121 review_state(self.repo, self.base, ("artifact.pipe",))122 123 @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.")124 def test_workspace_file_type_is_verified_after_open(self) -> None:125 """Do not trust a stale file-type check when reading workspace content."""126 fifo = self.repo / "artifact.pipe"127 os.mkfifo(fifo)128 129 with (130 mock.patch.object(Path, "is_file", return_value=True),131 mock.patch.object(Path, "read_bytes", return_value=b"not from the FIFO"),132 self.assertRaisesRegex(ValueError, "Unsupported workspace file type"),133 ):134 _workspace_entry(self.repo, "artifact.pipe")135 136 @unittest.skipUnless(hasattr(os, "mkfifo"), "Requires POSIX FIFO support.")137 def test_pathspec_file_type_is_verified_after_open(self) -> None:138 """Do not trust a path-based read when loading a task manifest."""139 fifo = self.root / "task.paths"140 os.mkfifo(fifo)141 142 with (143 mock.patch.object(Path, "read_text", return_value="src/runtime.py\n"),144 self.assertRaisesRegex(ValueError, "Cannot read pathspec file"),145 ):146 _load_pathspec_file(fifo)147 148 @unittest.skipIf(os.name == "nt", "Executable mode normalization requires POSIX.")149 def test_executable_uses_git_owner_bit(self) -> None:150 runtime = self.repo / "src" / "runtime.py"151 runtime.write_text("VALUE = 2\n")152 runtime.chmod(0o744)153 executable = review_state(self.repo, self.base, ("src/runtime.py",))154 155 runtime.chmod(0o654)156 non_executable = review_state(self.repo, self.base, ("src/runtime.py",))157 158 self.assertTrue(executable["workspace"][0]["executable"])159 self.assertFalse(non_executable["workspace"][0]["executable"])160 self.assertNotEqual(161 executable["content_fingerprint"],162 non_executable["content_fingerprint"],163 )164 165 def test_component_fingerprints_invalidate_only_changed_content(self) -> None:166 runtime = self.repo / "src" / "runtime.py"167 tests = self.repo / "tests" / "test_runtime.py"168 runtime.write_text("VALUE = 2\n")169 tests.write_text("assert 2 == 2\n")170 components = {"runtime": ("src",), "tests-examples": ("tests",)}171 before = review_state(self.repo, self.base, ("src", "tests"), components)172 173 tests.write_text("assert 2 != 1\n")174 after = review_state(self.repo, self.base, ("src", "tests"), components)175 176 self.assertEqual(177 before["components"]["runtime"]["content_fingerprint"],178 after["components"]["runtime"]["content_fingerprint"],179 )180 self.assertNotEqual(181 before["components"]["tests-examples"]["content_fingerprint"],182 after["components"]["tests-examples"]["content_fingerprint"],183 )184 self.assertNotEqual(before["content_fingerprint"], after["content_fingerprint"])185 186 def test_unfiltered_workspace_accounts_for_changes_outside_manifest(self) -> None:187 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")188 (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n")189 190 state = review_state(self.repo, self.base, ("src",))191 192 self.assertEqual(193 [entry["path"] for entry in state["workspace"]], ["src/runtime.py"]194 )195 self.assertEqual(196 [entry["path"] for entry in state["unfiltered"]["workspace"]],197 ["src/runtime.py", "tests/test_runtime.py"],198 )199 self.assertRegex(state["unfiltered"]["status_sha256"], r"^[0-9a-f]{64}$")200 201 def test_complete_diff_includes_task_owned_untracked_files(self) -> None:202 new_test = self.repo / "tests" / "test_new.py"203 new_test.write_text("assert 2 == 2\n")204 complete_diff = self.root / "complete.diff"205 206 state = review_state(207 self.repo,208 self.base,209 ("tests",),210 complete_diff_output=complete_diff,211 )212 213 diff = complete_diff.read_bytes()214 self.assertIn(b"diff --git a/tests/test_new.py b/tests/test_new.py", diff)215 self.assertIn(b"+assert 2 == 2", diff)216 self.assertEqual(217 state["complete_diff_sha256"], hashlib.sha256(diff).hexdigest()218 )219 self.assertEqual(220 state["complete_diff_paths"],221 ["tests/test_new.py"],222 )223 self.assertNotEqual(state["complete_diff_sha256"], state["tracked_diff_sha256"])224 225 def test_exact_manifest_path_includes_ignored_untracked_file(self) -> None:226 ignored = self.repo / "plans" / "private.md"227 ignored.write_text("shipped fixture\n")228 complete_diff = self.root / "complete.diff"229 230 state = review_state(231 self.repo,232 self.base,233 ("plans/private.md",),234 {"release-metadata": ("plans/private.md",)},235 complete_diff_output=complete_diff,236 )237 238 self.assertEqual(state["complete_diff_paths"], ["plans/private.md"])239 self.assertEqual(state["unfiltered"]["workspace"], state["workspace"])240 self.assertEqual(241 state["components"]["release-metadata"]["workspace"],242 state["workspace"],243 )244 self.assertIn(b"+shipped fixture", complete_diff.read_bytes())245 246 def test_directory_pathspec_does_not_promote_ignored_operational_files(247 self,248 ) -> None:249 (self.repo / "plans" / "private.md").write_text("operational plan\n")250 251 state = review_state(self.repo, self.base, ("plans",))252 253 self.assertEqual(state["workspace"], [])254 self.assertEqual(state["complete_diff_paths"], [])255 256 def test_literal_filename_with_pathspec_metacharacters_is_exact(self) -> None:257 (self.repo / "plans" / "[a].md").write_text("literal\n")258 (self.repo / "plans" / "a.md").write_text("glob match\n")259 260 state = review_state(self.repo, self.base, ("plans/[a].md",))261 262 self.assertEqual(state["complete_diff_paths"], ["plans/[a].md"])263 264 def test_existing_magic_prefixed_filename_is_exact(self) -> None:265 """Treat an existing magic-prefixed filename as an exact path."""266 (self.repo / ":(glob)literal").write_text("literal filename\n")267 (self.repo / "literal").write_text("glob match\n")268 269 state = review_state(self.repo, self.base, (":(glob)literal",))270 271 self.assertEqual(state["complete_diff_paths"], [":(glob)literal"])272 273 def test_deleted_magic_prefixed_filename_is_exact_from_base(self) -> None:274 """Treat a deleted base filename with magic syntax as exact."""275 magic_prefixed = self.repo / ":(glob)literal"276 magic_prefixed.write_text("deleted literal filename\n")277 (self.repo / "literal").write_text("glob match\n")278 self._git("add", ".")279 self._git("commit", "-qm", "add magic-prefixed filename")280 self.base = self._git("rev-parse", "HEAD").strip()281 self._git("rm", "-q", "--", ":(literal):(glob)literal")282 283 state = review_state(self.repo, self.base, (":(glob)literal",))284 285 self.assertEqual(state["complete_diff_paths"], [":(glob)literal"])286 287 def test_explicit_glob_magic_preserves_pattern_semantics(self) -> None:288 (self.repo / "plans" / "[a].md").write_text("literal\n")289 (self.repo / "plans" / "a.md").write_text("glob match\n")290 291 state = review_state(self.repo, self.base, (":(glob)plans/[a].md",))292 293 self.assertEqual(state["complete_diff_paths"], ["plans/[a].md", "plans/a.md"])294 295 def test_submodule_changes_require_reviewable_gitlinks(self) -> None:296 """Accept staged pointers and reject other submodule worktree changes."""297 source = self.repo / ".fixtures" / "dependency-source"298 source.mkdir(parents=True)299 subprocess.run(("git", "init", "-q", str(source)), check=True)300 subprocess.run(301 ("git", "-C", str(source), "config", "user.email", "submodule@example.test"),302 check=True,303 )304 subprocess.run(305 ("git", "-C", str(source), "config", "user.name", "Submodule Test"),306 check=True,307 )308 (source / "tracked.txt").write_text("committed\n")309 subprocess.run(("git", "-C", str(source), "add", "."), check=True)310 subprocess.run(("git", "-C", str(source), "commit", "-qm", "initial"), check=True)311 with (self.repo / ".gitignore").open("a") as gitignore:312 gitignore.write(".fixtures/\n")313 self._git(314 "-c",315 "protocol.file.allow=always",316 "submodule",317 "add",318 "-q",319 str(source),320 "vendor/dependency",321 )322 self._git(323 "config",324 "-f",325 ".gitmodules",326 "submodule.vendor/dependency.ignore",327 "all",328 )329 self._git("add", ".")330 self._git("commit", "-qm", "add dependency")331 self.base = self._git("rev-parse", "HEAD").strip()332 (source / "tracked.txt").write_text("updated commit\n")333 subprocess.run(("git", "-C", str(source), "commit", "-qam", "update"), check=True)334 updated_head = subprocess.check_output(335 ("git", "-C", str(source), "rev-parse", "HEAD"),336 text=True,337 ).strip()338 self._git("-C", "vendor/dependency", "fetch", "-q", "origin")339 self._git("-C", "vendor/dependency", "checkout", "-q", updated_head)340 341 with self.assertRaisesRegex(ValueError, "HEAD does not match.*vendor/dependency"):342 review_state(self.repo, self.base, ("vendor/dependency",))343 344 # Explicit staging must override the fixture's ignore=all setting.345 self._git("add", "--force", "vendor/dependency")346 clean_state = review_state(self.repo, self.base, ("vendor/dependency",))347 348 self.assertEqual(349 clean_state["workspace"],350 [351 {352 "path": "vendor/dependency",353 "kind": "gitlink",354 "head": updated_head,355 }356 ],357 )358 tracked = self.repo / "vendor" / "dependency" / "tracked.txt"359 tracked.write_text("dirty body\n")360 361 with self.assertRaisesRegex(ValueError, "Dirty submodule.*vendor/dependency"):362 review_state(self.repo, self.base, ("vendor/dependency",))363 364 def test_materialized_uninitialized_gitlink_fails_closed(self) -> None:365 """Reject arbitrary directory content hidden behind an index gitlink."""366 self._git(367 "update-index",368 "--add",369 "--cacheinfo",370 f"160000,{self.base},vendor/dependency",371 )372 dependency = self.repo / "vendor" / "dependency"373 dependency.mkdir(parents=True)374 (dependency / "unreviewed.txt").write_text("first body\n")375 376 with self.assertRaisesRegex(ValueError, "Materialized gitlink.*vendor/dependency"):377 review_state(self.repo, self.base, ("vendor/dependency",))378 379 @unittest.skipIf(os.name == "nt", "Directory symlinks require platform privileges.")380 def test_cyclic_gitlink_worktree_fails_closed(self) -> None:381 """Reject a gitlink alias that resolves back to an ancestor repository."""382 self._git(383 "update-index",384 "--add",385 "--cacheinfo",386 f"160000,{self.base},vendor/self",387 )388 vendor = self.repo / "vendor"389 vendor.mkdir()390 os.symlink("..", vendor / "self", target_is_directory=True)391 original_limit = sys.getrecursionlimit()392 sys.setrecursionlimit(120)393 self.addCleanup(sys.setrecursionlimit, original_limit)394 395 with self.assertRaisesRegex(ValueError, "Cyclic submodule worktree"):396 review_state(self.repo, self.base, ("vendor/self",))397 398 def test_hidden_nested_submodule_changes_fail_closed(self) -> None:399 """Reject nested pointer and content changes hidden by configuration."""400 leaf_source = self.repo / ".fixtures" / "leaf-source"401 leaf_source.mkdir(parents=True)402 subprocess.run(("git", "init", "-q", str(leaf_source)), check=True)403 subprocess.run(404 ("git", "-C", str(leaf_source), "config", "user.email", "leaf@example.test"),405 check=True,406 )407 subprocess.run(408 ("git", "-C", str(leaf_source), "config", "user.name", "Leaf Test"),409 check=True,410 )411 (leaf_source / "tracked.txt").write_text("committed\n")412 subprocess.run(("git", "-C", str(leaf_source), "add", "."), check=True)413 subprocess.run(414 ("git", "-C", str(leaf_source), "commit", "-qm", "initial"),415 check=True,416 )417 418 parent_source = self.repo / ".fixtures" / "parent-source"419 parent_source.mkdir()420 subprocess.run(("git", "init", "-q", str(parent_source)), check=True)421 subprocess.run(422 ("git", "-C", str(parent_source), "config", "user.email", "parent@example.test"),423 check=True,424 )425 subprocess.run(426 ("git", "-C", str(parent_source), "config", "user.name", "Parent Test"),427 check=True,428 )429 subprocess.run(430 (431 "git",432 "-C",433 str(parent_source),434 "-c",435 "protocol.file.allow=always",436 "submodule",437 "add",438 "-q",439 str(leaf_source),440 "nested",441 ),442 check=True,443 )444 subprocess.run(445 (446 "git",447 "-C",448 str(parent_source),449 "config",450 "-f",451 ".gitmodules",452 "submodule.nested.ignore",453 "all",454 ),455 check=True,456 )457 subprocess.run(("git", "-C", str(parent_source), "add", ".gitmodules"), check=True)458 subprocess.run(("git", "-C", str(parent_source), "commit", "-qam", "initial"), check=True)459 460 with (self.repo / ".gitignore").open("a") as gitignore:461 gitignore.write(".fixtures/\n")462 self._git(463 "-c",464 "protocol.file.allow=always",465 "submodule",466 "add",467 "-q",468 str(parent_source),469 "vendor/dependency",470 )471 self._git(472 "-C",473 "vendor/dependency",474 "-c",475 "protocol.file.allow=always",476 "submodule",477 "update",478 "--init",479 "-q",480 )481 self._git("add", ".")482 self._git("commit", "-qm", "add nested dependency")483 self.base = self._git("rev-parse", "HEAD").strip()484 expected_nested_head = self._git(485 "-C",486 "vendor/dependency",487 "rev-parse",488 "HEAD:nested",489 ).strip()490 (leaf_source / "tracked.txt").write_text("updated commit\n")491 subprocess.run(492 ("git", "-C", str(leaf_source), "commit", "-qam", "update"),493 check=True,494 )495 updated_nested_head = subprocess.check_output(496 ("git", "-C", str(leaf_source), "rev-parse", "HEAD"),497 text=True,498 ).strip()499 self._git("-C", "vendor/dependency/nested", "fetch", "-q", "origin")500 self._git(501 "-C",502 "vendor/dependency/nested",503 "checkout",504 "-q",505 updated_nested_head,506 )507 parent_status = self._git("-C", "vendor/dependency", "status", "--porcelain=v1")508 509 self.assertEqual(parent_status, "")510 with self.assertRaisesRegex(511 ValueError,512 "HEAD does not match.*vendor/dependency/nested",513 ):514 review_state(self.repo, self.base, ("vendor/dependency",))515 516 self._git(517 "-C",518 "vendor/dependency/nested",519 "checkout",520 "-q",521 expected_nested_head,522 )523 tracked = self.repo / "vendor" / "dependency" / "nested" / "tracked.txt"524 tracked.write_text("dirty body\n")525 parent_status = self._git("-C", "vendor/dependency", "status", "--porcelain=v1")526 527 self.assertEqual(parent_status, "")528 with self.assertRaisesRegex(529 ValueError,530 "Dirty submodule.*vendor/dependency/nested",531 ):532 review_state(self.repo, self.base, ("vendor/dependency",))533 534 @unittest.skipIf(os.name == "nt", "Non-UTF-8 filenames require POSIX filesystem bytes.")535 def test_non_utf8_filename_has_stable_fingerprint(self) -> None:536 """Preserve surrogateescaped Git path bytes in review artifacts."""537 raw_relative_path = b"tests/non-utf8-\xff.py"538 git = (b"git", b"-C", os.fsencode(self.repo))539 blob = subprocess.check_output(540 (*git, b"hash-object", b"-w", b"--stdin"),541 input=b"assert True\n",542 ).strip()543 subprocess.run(544 (545 *git,546 b"update-index",547 b"--add",548 b"--cacheinfo",549 b"100644," + blob + b"," + raw_relative_path,550 ),551 check=True,552 )553 self._git("commit", "-qm", "add non-UTF-8 filename")554 self.base = self._git("rev-parse", "HEAD").strip()555 subprocess.run(556 (*git, b"update-index", b"--force-remove", b"--", raw_relative_path),557 check=True,558 )559 relative_path = os.fsdecode(raw_relative_path)560 561 state = review_state(self.repo, self.base, ("tests",))562 563 self.assertEqual(state["complete_diff_paths"], [relative_path])564 self.assertEqual(565 _content_fingerprint(state["base"], state["workspace"]),566 state["content_fingerprint"],567 )568 json.dumps(state, ensure_ascii=True)569 570 completed = self._run_cli("--pathspec", "tests")571 572 self.assertEqual(completed.returncode, 0, completed.stderr)573 cli_state = json.loads(completed.stdout)574 self.assertEqual(cli_state["complete_diff_paths"], [relative_path])575 self.assertEqual(cli_state["content_fingerprint"], state["content_fingerprint"])576 577 def test_complete_diff_output_must_be_outside_repository(self) -> None:578 """Reject an operational diff artifact inside the worktree."""579 complete_diff = self.repo / "complete.diff"580 581 with self.assertRaisesRegex(ValueError, "outside the repository"):582 review_state(583 self.repo,584 self.base,585 complete_diff_output=complete_diff,586 )587 588 self.assertFalse(complete_diff.exists())589 590 def test_complete_diff_output_rejects_case_alias_inside_repository(self) -> None:591 """Reject case aliases that resolve to the worktree on this filesystem."""592 alternate_repo = self.repo.with_name(self.repo.name.swapcase())593 if not alternate_repo.exists() or not alternate_repo.samefile(self.repo):594 self.skipTest("Filesystem is case-sensitive.")595 complete_diff = alternate_repo / "complete.diff"596 597 with self.assertRaisesRegex(ValueError, "outside the repository"):598 review_state(599 self.repo,600 self.base,601 complete_diff_output=complete_diff,602 )603 604 self.assertFalse(complete_diff.exists())605 606 def test_complete_diff_output_does_not_follow_hardlink_into_repository(self) -> None:607 """Replace an outside hardlink without mutating its repository peer."""608 runtime = self.repo / "src" / "runtime.py"609 complete_diff = self.root / "complete.diff"610 os.link(runtime, complete_diff)611 (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n")612 613 state = review_state(614 self.repo,615 self.base,616 complete_diff_output=complete_diff,617 )618 619 self.assertEqual(runtime.read_text(), "VALUE = 1\n")620 self.assertEqual(621 hashlib.sha256(complete_diff.read_bytes()).hexdigest(),622 state["complete_diff_sha256"],623 )624 625 def test_external_complete_diff_output_keeps_state_stable(self) -> None:626 """Keep consecutive review states stable when writing an artifact."""627 complete_diff = self.root / "complete.diff"628 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")629 630 first = review_state(631 self.repo,632 self.base,633 complete_diff_output=complete_diff,634 )635 second = review_state(636 self.repo,637 self.base,638 complete_diff_output=complete_diff,639 )640 641 self.assertEqual(first, second)642 self.assertEqual(643 hashlib.sha256(complete_diff.read_bytes()).hexdigest(),644 second["complete_diff_sha256"],645 )646 647 def test_assume_unchanged_paths_fail_closed(self) -> None:648 """Reject index flags that can hide worktree content changes."""649 self._git("update-index", "--assume-unchanged", "src/runtime.py")650 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")651 652 with self.assertRaisesRegex(ValueError, "assume-unchanged.*src/runtime.py"):653 review_state(self.repo, self.base, ("src/runtime.py",))654 655 def test_materialized_skip_worktree_paths_fail_closed(self) -> None:656 """Reject materialized sparse paths that can hide worktree changes."""657 self._git("update-index", "--skip-worktree", "src/runtime.py")658 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")659 660 with self.assertRaisesRegex(ValueError, "skip-worktree.*src/runtime.py"):661 review_state(self.repo, self.base, ("src/runtime.py",))662 663 def test_unmerged_index_paths_fail_closed(self) -> None:664 """Reject unresolved index stages before fingerprinting worktree content."""665 self._git("checkout", "-qb", "other")666 (self.repo / "src" / "runtime.py").write_text("VALUE = 'other'\n")667 self._git("commit", "-qam", "other change")668 self._git("checkout", "-qb", "current", self.base)669 (self.repo / "src" / "runtime.py").write_text("VALUE = 'current'\n")670 self._git("commit", "-qam", "current change")671 merged = subprocess.run(672 ("git", "-C", str(self.repo), "merge", "other"),673 capture_output=True,674 text=True,675 )676 self.assertEqual(merged.returncode, 1)677 678 with self.assertRaisesRegex(ValueError, "unmerged=src/runtime.py"):679 review_state(self.repo, self.base, ("src/runtime.py",))680 681 def test_ordinary_directory_is_not_a_gitlink(self) -> None:682 """Do not discover the parent repository through a directory."""683 self.assertEqual(684 _workspace_entry(self.repo, "plans"),685 {"path": "plans", "kind": "directory"},686 )687 688 def test_untracked_nested_repository_fails_closed(self) -> None:689 """Reject embedded repositories that have no reviewable gitlink."""690 nested = self.repo / "nested"691 subprocess.run(("git", "init", "-q", str(nested)), check=True)692 (nested / "untracked.txt").write_text("not represented by a gitlink\n")693 694 with self.assertRaisesRegex(ValueError, "Untracked nested Git repositories.*nested"):695 review_state(self.repo, self.base)696 697 def test_cli_writes_complete_diff_output(self) -> None:698 (self.repo / "tests" / "test_new.py").write_text("assert True\n")699 complete_diff = self.root / "complete.diff"700 701 completed = self._run_cli(702 "--pathspec",703 "tests",704 "--complete-diff-output",705 str(complete_diff),706 )707 708 self.assertEqual(completed.returncode, 0, completed.stderr)709 state = json.loads(completed.stdout)710 self.assertEqual(711 state["complete_diff_sha256"],712 hashlib.sha256(complete_diff.read_bytes()).hexdigest(),713 )714 715 def test_repository_fingerprint_includes_outside_manifest_state_and_content(716 self,717 ) -> None:718 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")719 before = review_state(self.repo, self.base, ("src",))720 721 outside = self.repo / "outside.txt"722 outside.write_text("first\n")723 after_add = review_state(self.repo, self.base, ("src",))724 outside.write_text("second\n")725 after_content = review_state(self.repo, self.base, ("src",))726 727 self.assertEqual(728 before["content_fingerprint"], after_add["content_fingerprint"]729 )730 self.assertEqual(731 after_add["content_fingerprint"], after_content["content_fingerprint"]732 )733 self.assertNotEqual(734 before["repository_fingerprint"], after_add["repository_fingerprint"]735 )736 self.assertNotEqual(737 after_add["repository_fingerprint"], after_content["repository_fingerprint"]738 )739 740 def test_pathspec_file_preserves_literal_values_and_deduplicates(self) -> None:741 manifest = self.repo / "paths.txt"742 manifest.write_text("src\n\n#literal\n lead.py\nsrc\n")743 744 self.assertEqual(_load_pathspec_file(manifest), ("src", "#literal", " lead.py"))745 746 def test_direct_pathspec_preserves_leading_space(self) -> None:747 (self.repo / " lead.py").write_text("VALUE = 2\n")748 749 completed = self._run_cli("--pathspec", " lead.py")750 751 self.assertEqual(completed.returncode, 0, completed.stderr)752 state = json.loads(completed.stdout)753 self.assertEqual([entry["path"] for entry in state["workspace"]], [" lead.py"])754 755 def test_empty_direct_pathspec_fails_closed(self) -> None:756 completed = self._run_cli("--pathspec", "")757 758 self.assertEqual(completed.returncode, 2)759 self.assertIn("Pathspecs must not be empty", completed.stderr)760 self.assertNotIn("Traceback", completed.stderr)761 762 def test_invalid_manifest_files_are_parser_errors(self) -> None:763 cases = (764 ("--pathspec-file", str(self.repo / "missing.paths")),765 ("--pathspec-file", str(self.repo)),766 ("--component-pathspec-file", "runtime="),767 ("--component-pathspec-file", f"runtime={self.repo / 'missing.paths'}"),768 )769 for arguments in cases:770 with self.subTest(arguments=arguments):771 completed = self._run_cli(*arguments)772 self.assertEqual(completed.returncode, 2)773 self.assertIn("error:", completed.stderr)774 self.assertNotIn("Traceback", completed.stderr)775 776 def test_invalid_repository_is_a_parser_error(self) -> None:777 missing_repo = self.repo / "missing-repo"778 completed = subprocess.run(779 (780 sys.executable,781 str(Path(__file__).with_name("review_state.py")),782 "--repo",783 str(missing_repo),784 "--base",785 self.base,786 ),787 capture_output=True,788 text=True,789 )790 791 self.assertEqual(completed.returncode, 2)792 self.assertIn("Git command failed", completed.stderr)793 self.assertNotIn("fatal:", completed.stderr)794 self.assertNotIn("Traceback", completed.stderr)795 796 def test_invalid_base_is_a_parser_error(self) -> None:797 completed = self._run_cli("--base", "missing-revision")798 799 self.assertEqual(completed.returncode, 2)800 self.assertIn("Git command failed", completed.stderr)801 self.assertNotIn("fatal:", completed.stderr)802 self.assertNotIn("Traceback", completed.stderr)803 804 def test_non_ancestor_base_is_a_parser_error(self) -> None:805 self._git("checkout", "-qb", "sibling")806 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")807 self._git("commit", "-qam", "sibling change")808 sibling = self._git("rev-parse", "HEAD").strip()809 self._git("checkout", "-qb", "current", self.base)810 (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n")811 self._git("commit", "-qam", "head change")812 813 completed = self._run_cli("--base", sibling)814 815 self.assertEqual(completed.returncode, 2)816 self.assertIn("Base must be an ancestor of HEAD", completed.stderr)817 self.assertNotIn("fatal:", completed.stderr)818 self.assertNotIn("Traceback", completed.stderr)819 820 def test_component_manifests_must_cover_combined_content(self) -> None:821 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")822 (self.repo / "tests" / "test_runtime.py").write_text("assert 2 == 2\n")823 824 with self.assertRaisesRegex(ValueError, "missing=.*test_runtime.py"):825 review_state(self.repo, self.base, ("src", "tests"), {"runtime": ("src",)})826 827 def test_component_manifests_must_not_overlap(self) -> None:828 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")829 830 with self.assertRaisesRegex(ValueError, "overlapping=.*runtime.py"):831 review_state(832 self.repo,833 self.base,834 ("src",),835 {"runtime": ("src",), "tests-examples": ("src/runtime.py",)},836 )837 838 def test_components_define_combined_scope_when_pathspecs_are_omitted(self) -> None:839 (self.repo / "src" / "runtime.py").write_text("VALUE = 2\n")840 state = review_state(self.repo, self.base, components={"runtime": ("src",)})841 842 self.assertEqual(state["pathspecs"], ["src"])843 self.assertEqual(844 [entry["path"] for entry in state["workspace"]], ["src/runtime.py"]845 )846 847 def test_component_cli_value(self) -> None:848 self.assertEqual(_component("runtime=src"), ("runtime", "src"))849 850 851if __name__ == "__main__":852 unittest.main()853