diff --git a/scripts/install_shared_generated_guard.py b/scripts/install_shared_generated_guard.py new file mode 100644 index 00000000..dd106744 --- /dev/null +++ b/scripts/install_shared_generated_guard.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Add the generated-commit guard to an existing shared hook directory. + +Never changes core.hooksPath, other hooks, or a pre-existing different guard. +The repo argument discovers the already-configured hook path; it is not a new +fleet routing or authority configuration. +""" +import argparse +import os +from pathlib import Path +import shlex +import subprocess + + +CHECKER = Path(__file__).with_name("check_generated_only_commit.py").resolve() + + +def install(repo, execute=False): + repo = Path(repo).resolve() + configured = subprocess.check_output( + ["git", "-C", str(repo), "config", "--get", "core.hooksPath"], text=True).strip() + directory = Path(configured) + if not directory.is_absolute(): + directory = repo / directory + if not directory.is_dir() or directory.is_symlink(): + raise ValueError("existing hook directory must be real and present") + target = directory / "pre-commit" + content = ("#!/bin/sh\n# Custodian shared generated-commit guard; preserve other hooks.\n" + "set -eu\nexec python3 " + shlex.quote(str(CHECKER)) + " --staged\n") + if target.is_symlink(): + raise ValueError("refuse redirected existing pre-commit hook") + if target.exists(): + if target.read_text() != content: + raise ValueError("existing pre-commit hook differs; owner integration required") + return {"repo": str(repo), "hook": str(target), "status": "installed", "changed": False} + if execute: + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755) + with os.fdopen(fd, "w") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + return {"repo": str(repo), "hook": str(target), + "status": "installed" if execute else "missing", "changed": execute} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--install", action="store_true") + args = parser.parse_args() + import json + try: + print(json.dumps(install(args.repo, args.install), sort_keys=True)) + except (ValueError, OSError, subprocess.SubprocessError): + parser.exit(1, "Shared hook unverified; existing files and routing are not replaced.\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_shared_generated_guard.py b/tests/test_shared_generated_guard.py new file mode 100644 index 00000000..c8a284ef --- /dev/null +++ b/tests/test_shared_generated_guard.py @@ -0,0 +1,55 @@ +import importlib.util +from pathlib import Path +import subprocess + +import pytest + + +SCRIPT = Path(__file__).parents[1] / "scripts/install_shared_generated_guard.py" +spec = importlib.util.spec_from_file_location("shared_guard", SCRIPT) +guard = importlib.util.module_from_spec(spec) +spec.loader.exec_module(guard) + + +def repository(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", str(repo)], check=True, capture_output=True) + hooks = tmp_path / "shared-hooks" + hooks.mkdir() + subprocess.run(["git", "-C", str(repo), "config", "core.hooksPath", str(hooks)], check=True) + return repo, hooks + + +def test_real_shared_hook_rejects_generated_only_and_retains_other_hooks(tmp_path): + repo, hooks = repository(tmp_path) + other = hooks / "prepare-commit-msg" + other.write_text("# existing owner hook\n") + before = other.read_bytes() + assert guard.install(repo)["status"] == "missing" + assert not (hooks / "pre-commit").exists() + assert guard.install(repo, True)["changed"] + assert not guard.install(repo, True)["changed"] + (repo / "WORK-RECORDS.md").write_text("derived\n") + subprocess.run(["git", "-C", str(repo), "add", "WORK-RECORDS.md"], check=True) + assert subprocess.run([str(hooks / "pre-commit")], cwd=repo, capture_output=True).returncode == 1 + (repo / "implementation.py").write_text("print('source')\n") + subprocess.run(["git", "-C", str(repo), "add", "implementation.py"], check=True) + assert subprocess.run([str(hooks / "pre-commit")], cwd=repo, capture_output=True).returncode == 0 + assert other.read_bytes() == before + + +def test_foreign_or_redirected_guard_is_never_replaced(tmp_path): + repo, hooks = repository(tmp_path) + hook = hooks / "pre-commit" + hook.write_text("owner implementation\n") + with pytest.raises(ValueError): + guard.install(repo, True) + assert hook.read_text() == "owner implementation\n" + hook.unlink() + private = tmp_path / "unrelated" + private.write_text("retained\n") + hook.symlink_to(private) + with pytest.raises(ValueError): + guard.install(repo, True) + assert private.read_text() == "retained\n" diff --git a/workplans/CUST-WP-0079-deterministic-security-flow.md b/workplans/CUST-WP-0079-deterministic-security-flow.md index cd17dd76..5df3e539 100644 --- a/workplans/CUST-WP-0079-deterministic-security-flow.md +++ b/workplans/CUST-WP-0079-deterministic-security-flow.md @@ -140,6 +140,13 @@ tests prove that owner completion admits the successor and that an unknown predecessor or stale progress label cannot produce readiness. This is queue qualification, not evidence that the corresponding live work has finished. +`scripts/install_shared_generated_guard.py` now checks or installs the guard in +the repository's already configured shared hook directory. It preserves existing +hook routing and other hooks, and refuses a differing or redirected pre-commit +hook. Real temporary-repository tests prove rejection and source-change acceptance. +The missing shared guard was installed for the existing repo-manager hook path; +the week's fleet evidence and generator adoption still remain open. + ## Attended window #1 ```task