100 lines
2.8 KiB
Python
100 lines
2.8 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Install the opt-in local git hook workflow for the C++ template project."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import os
|
||
|
|
import pathlib
|
||
|
|
import stat
|
||
|
|
import subprocess
|
||
|
|
from collections.abc import Sequence
|
||
|
|
|
||
|
|
from typing import cast
|
||
|
|
|
||
|
|
from lib import common
|
||
|
|
|
||
|
|
STATUS_PASS = "PASS"
|
||
|
|
STATUS_FAIL = "FAIL"
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
||
|
|
args = build_parser().parse_args(argv)
|
||
|
|
root = common.find_project_root(pathlib.Path(__file__))
|
||
|
|
hooks_dir = root / ".githooks"
|
||
|
|
hook_path = hooks_dir / "pre-commit"
|
||
|
|
|
||
|
|
status = cast(bool, args.status)
|
||
|
|
dry_run = cast(bool, args.dry_run)
|
||
|
|
|
||
|
|
if status:
|
||
|
|
return report_status(root, hooks_dir, hook_path)
|
||
|
|
|
||
|
|
plan = build_plan(root)
|
||
|
|
if dry_run:
|
||
|
|
common.print_plan(plan)
|
||
|
|
print(f"{STATUS_PASS}: dry-run planned opt-in local hook setup")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
print("local git hook setup")
|
||
|
|
common.print_plan(plan)
|
||
|
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
if not hook_path.is_file():
|
||
|
|
print(f"{STATUS_FAIL}: expected hook file is missing: {hook_path}")
|
||
|
|
return 2
|
||
|
|
|
||
|
|
ensure_executable(hook_path)
|
||
|
|
return common.run_plans(plan, dry_run=False)
|
||
|
|
|
||
|
|
|
||
|
|
def build_parser() -> argparse.ArgumentParser:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
_ = parser.add_argument("--dry-run", action="store_true", help="Print planned setup without writing hooks or git config.")
|
||
|
|
_ = parser.add_argument("--status", action="store_true", help="Report local hook status without mutating files or git config.")
|
||
|
|
return parser
|
||
|
|
|
||
|
|
|
||
|
|
def build_plan(root: pathlib.Path) -> tuple[common.CommandPlan, ...]:
|
||
|
|
return (
|
||
|
|
common.CommandPlan(
|
||
|
|
label="set-local-hooks-path",
|
||
|
|
command=("git", "config", "--local", "core.hooksPath", ".githooks"),
|
||
|
|
cwd=root,
|
||
|
|
required_tool="git",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def report_status(root: pathlib.Path, hooks_dir: pathlib.Path, hook_path: pathlib.Path) -> int:
|
||
|
|
print(f"hooks_dir: {hooks_dir}")
|
||
|
|
print(f"pre_commit_hook_exists: {hook_path.is_file()}")
|
||
|
|
print(f"pre_commit_hook_executable: {os.access(hook_path, os.X_OK)}")
|
||
|
|
print(f"local core.hooksPath: {local_hooks_path(root)}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def local_hooks_path(root: pathlib.Path) -> str:
|
||
|
|
result = subprocess.run(
|
||
|
|
["git", "config", "--local", "--get", "core.hooksPath"],
|
||
|
|
cwd=str(root),
|
||
|
|
check=False,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
)
|
||
|
|
if result.returncode == 0:
|
||
|
|
return result.stdout.strip()
|
||
|
|
return "<unset>"
|
||
|
|
|
||
|
|
|
||
|
|
def ensure_executable(path: pathlib.Path) -> None:
|
||
|
|
current_mode = path.stat().st_mode
|
||
|
|
executable_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||
|
|
if current_mode & executable_bits == executable_bits:
|
||
|
|
return
|
||
|
|
path.chmod(current_mode | executable_bits)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|