diff --git a/scripts/visual_evidence.py b/scripts/visual_evidence.py index 7188f19..53a470a 100644 --- a/scripts/visual_evidence.py +++ b/scripts/visual_evidence.py @@ -31,7 +31,19 @@ def load_json(path: Path, label: str) -> dict[str, Any]: return value +def comparison_commit(root: Path, reference: str) -> str: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--end-of-options", f"{reference}^{{commit}}"], + cwd=root, check=False, capture_output=True, text=True, + ) + commit = result.stdout.strip() + if result.returncode or re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", commit) is None: + raise VisualEvidenceError("The comparison base must resolve to one Git commit") + return commit + + def changed_paths(root: Path, base_ref: str) -> list[str]: + commit = comparison_commit(root, base_ref) result = subprocess.run( [ "git", @@ -40,7 +52,7 @@ def changed_paths(root: Path, base_ref: str) -> list[str]: "--no-renames", "--diff-filter=ACDMRTUXB", "-z", - f"{base_ref}...HEAD", + f"{commit}...HEAD", ], cwd=root, check=True, @@ -55,8 +67,9 @@ def file_content(root: Path, path: str, base_ref: str | None = None) -> str: if candidate.is_file(): contents.append(candidate.read_text(encoding="utf-8", errors="replace")) if base_ref: + commit = comparison_commit(root, base_ref) result = subprocess.run( - ["git", "show", f"{base_ref}:{path}"], + ["git", "show", f"{commit}:{path}"], cwd=root, check=False, capture_output=True, diff --git a/tests/test_visual_evidence.py b/tests/test_visual_evidence.py index cd850a5..e7646f6 100644 --- a/tests/test_visual_evidence.py +++ b/tests/test_visual_evidence.py @@ -15,6 +15,7 @@ VisualEvidenceError, changed_paths, classify_changes, + file_content, load_json, validate_manifest, validate_report, @@ -25,6 +26,56 @@ SCHEMA_PATH = ROOT / "visual-evidence" / "schema.json" +class VisualEvidenceGitBoundaryTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) / "repository" + self.output = Path(temporary.name) / "outside" + self.root.mkdir() + self.output.mkdir() + self.git("init", "-q") + (self.root / "source.css").write_text("old selector\n") + self.git("add", "source.css") + self.git("commit", "-qm", "baseline") + self.base = self.git("rev-parse", "HEAD").stdout.strip() + self.git("branch", "comparison-base") + (self.root / "source.css").unlink() + self.git("add", "source.css") + self.git("commit", "-qm", "remove source") + + def git(self, *arguments): + return subprocess.run( + ["git", "-c", "user.name=Visual Evidence Test", "-c", + "user.email=visual-evidence@example.invalid", "-c", "commit.gpgsign=false", *arguments], + cwd=self.root, check=True, capture_output=True, text=True, + ) + + def test_sha_branch_and_relative_commit_keep_diff_and_deleted_content(self): + for reference in (self.base, "comparison-base", "HEAD~1"): + with self.subTest(reference=reference): + self.assertEqual(["source.css"], changed_paths(self.root, reference)) + self.assertEqual("old selector\n", file_content(self.root, "source.css", reference)) + + def test_option_shaped_refs_refuse_without_writing_outside_repository(self): + reference = "--output=" + str(self.output / "injected") + calls = ((changed_paths, (self.root, reference)), + (file_content, (self.root, "source.css", reference))) + for function, arguments in calls: + with self.subTest(function=function.__name__): + with self.assertRaises(VisualEvidenceError): + function(*arguments) + self.assertEqual([], list(self.output.iterdir())) + + def test_missing_and_noncommit_refs_refuse(self): + for reference in ("missing-reference", self.base + ":source.css"): + for function, arguments in ((changed_paths, (self.root, reference)), + (file_content, (self.root, "source.css", reference))): + with self.subTest(reference=reference, function=function.__name__): + with self.assertRaises(VisualEvidenceError): + function(*arguments) + + class VisualEvidencePolicyTest(unittest.TestCase): def setUp(self) -> None: self.policy = load_json(POLICY_PATH, "visual evidence policy")