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