From 2c80009c70eeb2475c5332c7027e8daa8b95c86a Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:15:14 +0000 Subject: [PATCH] Scope pre-push history checks to pushed refs Read Git's pre-push ref updates and inspect the full reachable history of each non-deleted local object ID. Ignore unrelated refs while rejecting private artifacts in deleted files, merge-only paths, and stored history masked by local replacements. Add regression coverage for branches, tags, raw revisions, multiple updates, deletion-only pushes, and artifact detection. Co-Authored-By: OpenCode gpt-6.1-sol --- .githooks/pre-push | 2 +- CONTRIBUTING.md | 2 + tests/test_pre_push.py | 147 +++++++++++++++++++++++++++++++++++++ tools/check_public_tree.py | 29 +++++++- 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 tests/test_pre_push.py diff --git a/.githooks/pre-push b/.githooks/pre-push index a886ccf0c..d067dd226 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,2 +1,2 @@ #!/bin/sh -exec python3 tools/check_public_tree.py --history +exec python3 tools/check_public_tree.py --pre-push diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5191ea1f7..e6e959186 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,8 @@ probe against Horizon, and compares their outputs. There are about 290 of them. Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects them (`git config core.hooksPath .githooks` runs it before every commit). +The pre-push hook checks the full history reachable from the branches and tags being pushed. Run +`python3 tools/check_public_tree.py --history` to audit the history of all local refs. ## Only original code diff --git a/tests/test_pre_push.py b/tests/test_pre_push.py new file mode 100644 index 000000000..4acd74953 --- /dev/null +++ b/tests/test_pre_push.py @@ -0,0 +1,147 @@ +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() diff --git a/tools/check_public_tree.py b/tools/check_public_tree.py index 7ca8b6217..fc92dd806 100644 --- a/tools/check_public_tree.py +++ b/tools/check_public_tree.py @@ -22,19 +22,40 @@ def prohibited(path): return bool(parsed.parts and parsed.parts[0] in PRIVATE_ROOTS) or parsed.suffix.lower() in PRIVATE_EXTENSIONS -def paths_in_repository(history=False): +def paths_in_repository(history=False, revisions=None): if not history: result = subprocess.check_output(["git", "ls-files", "-z"]) return result.decode("utf-8").split("\0") - result = subprocess.check_output(["git", "log", "--all", "--format=", "--name-only", "-z"]) + # An empty push (including deletions only) must not fall back to HEAD or --all. + if revisions is not None and not revisions: + return [] + roots = ["--all"] if revisions is None else revisions + # Include diffs against merge parents so merge-resolution-only artifacts are caught. + # Git pushes the stored objects, not the history presented by local replace refs. + result = subprocess.check_output(["git", "--no-replace-objects", "log", "--format=", "--name-only", + "-z", "-m", *roots, "--"]) return [path.lstrip("\n") for path in result.decode("utf-8").split("\0")] +def pushed_revisions(stream): + """Read Git's pre-push ref updates and retain the non-deleted local object IDs.""" + revisions = [] + for line in stream: + _local_ref, local_oid, _remote_ref, _remote_oid = line.split() + if local_oid.strip("0"): + revisions.append(local_oid) + return revisions + + def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--history", action="store_true", help="Also inspect paths in all local Git history") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--history", action="store_true", help="Inspect paths in all local Git history") + mode.add_argument("--pre-push", action="store_true", help="Inspect history reachable from Git's ref updates on stdin") args = parser.parse_args() - rejected = sorted({path for path in paths_in_repository(args.history) if path and prohibited(path)}) + revisions = pushed_revisions(sys.stdin) if args.pre_push else None + rejected = sorted({path for path in paths_in_repository(args.history or args.pre_push, revisions) + if path and prohibited(path)}) if rejected: print("Private game/profiling artifacts must stay outside Git:", file=sys.stderr) for path in rejected: