874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
148 lines
6.9 KiB
Python
148 lines
6.9 KiB
Python
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ZERO_OID = "0" * 40
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("sh") and shutil.which("python3"), "Git hook needs sh and python3")
|
|
class PrePushTests(unittest.TestCase):
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
self.repo = Path(temporary.name)
|
|
self.env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
|
|
self.git("init", "-b", "main")
|
|
(self.repo / "tools").mkdir()
|
|
shutil.copyfile(ROOT / "tools/check_public_tree.py", self.repo / "tools/check_public_tree.py")
|
|
(self.repo / ".gitignore").write_text("/.local/\n")
|
|
(self.repo / "README.md").write_text("Public fixture\n")
|
|
self.git("add", ".gitignore", "README.md")
|
|
self.git("commit", "-m", "Public root")
|
|
self.base = self.git("rev-parse", "HEAD")
|
|
|
|
def git(self, *args):
|
|
return subprocess.check_output(
|
|
["git", "-c", f"core.hooksPath={self.repo / 'no-hooks'}", "-c", "user.name=Test",
|
|
"-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false",
|
|
"-c", "tag.gpgsign=false", *args],
|
|
cwd=self.repo, env=self.env, text=True, stderr=subprocess.PIPE).strip()
|
|
|
|
def commit_file(self, path, content="Public fixture\n"):
|
|
destination = self.repo / path
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(content)
|
|
self.git("add", "-f", path)
|
|
self.git("commit", "-m", f"Add {path}")
|
|
return self.git("rev-parse", "HEAD")
|
|
|
|
def private_commit(self):
|
|
return self.commit_file(".local/artifact.txt", "Private fixture, not game data\n")
|
|
|
|
def update(self, local_ref, local_oid, remote_ref=None, remote_oid=ZERO_OID):
|
|
return f"{local_ref} {local_oid} {remote_ref or local_ref} {remote_oid}\n"
|
|
|
|
def push(self, *updates):
|
|
return subprocess.run(["sh", str(ROOT / ".githooks/pre-push"), "origin", "unused"],
|
|
cwd=self.repo, env=self.env, input="".join(updates),
|
|
text=True, capture_output=True)
|
|
|
|
def assert_passed(self, result):
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("Public tree check passed.", result.stdout)
|
|
|
|
def assert_private_rejected(self, result):
|
|
self.assertNotEqual(result.returncode, 0, result.stdout)
|
|
self.assertIn(".local/artifact.txt", result.stderr)
|
|
|
|
def test_clean_branch_ignores_unpushed_refs_and_head(self):
|
|
self.git("checkout", "-b", "local-only")
|
|
private = self.private_commit()
|
|
for ref in ["refs/remotes/archive/private", "refs/original/refs/heads/main", "refs/tags/private"]:
|
|
self.git("update-ref", ref, private)
|
|
self.git("branch", "clean", self.base)
|
|
for remote_oid in [ZERO_OID, private]:
|
|
with self.subTest(remote_oid=remote_oid):
|
|
self.assert_passed(self.push(self.update("refs/heads/clean", self.base, remote_oid=remote_oid)))
|
|
# The standalone audit still checks every local ref.
|
|
audit = subprocess.run([sys.executable, "tools/check_public_tree.py", "--history"],
|
|
cwd=self.repo, env=self.env, text=True, capture_output=True)
|
|
self.assert_private_rejected(audit)
|
|
|
|
def test_raw_revision_push_uses_supplied_oid(self):
|
|
self.private_commit()
|
|
self.assert_passed(self.push(self.update(self.base, self.base, "refs/heads/public")))
|
|
|
|
def test_unpushed_replacement_does_not_taint_clean_history(self):
|
|
private = self.private_commit()
|
|
tree = self.git("rev-parse", f"{private}^{{tree}}")
|
|
replacement = self.git("commit-tree", tree, "-m", "Private replacement root")
|
|
self.git("replace", self.base, replacement)
|
|
self.assert_passed(self.push(self.update(self.base, self.base, "refs/heads/public")))
|
|
|
|
def test_replacement_cannot_hide_private_history(self):
|
|
private = self.private_commit()
|
|
self.git("replace", private, self.base)
|
|
self.assert_private_rejected(self.push(self.update("refs/heads/main", private)))
|
|
|
|
def test_all_pushed_refs_are_checked(self):
|
|
private = self.private_commit()
|
|
clean_update = self.update("refs/heads/clean", self.base)
|
|
private_update = self.update("refs/heads/main", private)
|
|
for updates in [(clean_update, private_update), (private_update, clean_update)]:
|
|
with self.subTest(updates=updates):
|
|
self.assert_private_rejected(self.push(*updates))
|
|
|
|
def test_private_ancestors_are_rejected_even_after_removal_and_an_earlier_push(self):
|
|
self.private_commit()
|
|
self.git("rm", ".local/artifact.txt")
|
|
self.git("commit", "-m", "Remove private fixture")
|
|
remote = self.git("rev-parse", "HEAD")
|
|
tip = self.commit_file("public.txt")
|
|
for remote_oid in [ZERO_OID, remote]:
|
|
with self.subTest(remote_oid=remote_oid):
|
|
self.assert_private_rejected(self.push(self.update("refs/heads/main", tip, remote_oid=remote_oid)))
|
|
|
|
def test_annotated_tags_check_their_target_history(self):
|
|
self.private_commit()
|
|
self.git("rm", ".local/artifact.txt")
|
|
self.git("commit", "-m", "Remove private fixture")
|
|
self.git("tag", "-a", "clean", self.base, "-m", "Clean tag")
|
|
self.git("tag", "-a", "private", "-m", "Private history tag")
|
|
clean = self.git("rev-parse", "refs/tags/clean")
|
|
private = self.git("rev-parse", "refs/tags/private")
|
|
self.assert_passed(self.push(self.update("refs/tags/clean", clean)))
|
|
self.assert_private_rejected(self.push(self.update("refs/tags/private", private)))
|
|
|
|
def test_empty_push_and_deletions_do_not_scan_unpushed_history(self):
|
|
private = self.private_commit()
|
|
deletion = self.update("(delete)", ZERO_OID, "refs/heads/private", private)
|
|
clean_update = self.update(self.base, self.base, "refs/heads/public")
|
|
for updates in [(), (deletion,), (deletion, clean_update)]:
|
|
with self.subTest(updates=updates):
|
|
self.assert_passed(self.push(*updates))
|
|
|
|
def test_private_file_introduced_only_by_merge_resolution_is_rejected(self):
|
|
self.git("checkout", "-b", "feature")
|
|
self.commit_file("feature.txt")
|
|
self.git("checkout", "main")
|
|
self.commit_file("main.txt")
|
|
self.git("merge", "--no-ff", "--no-commit", "feature")
|
|
merge = self.private_commit()
|
|
self.assert_private_rejected(self.push(self.update("refs/heads/main", merge)))
|
|
|
|
def test_invalid_pushed_oid_fails_closed(self):
|
|
result = self.push(self.update("refs/heads/missing", "f" * 40))
|
|
self.assertNotEqual(result.returncode, 0, result.stdout)
|
|
self.assertNotIn("Public tree check passed.", result.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|