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 <noreply@openai.com>
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --history
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user