|
4 | 4 | """ |
5 | 5 | Migration history must be immutable. This hook enforces that by preventing deletion or updates to migration scripts. |
6 | 6 |
|
7 | | -Checks both staged changes (local pre-commit) and the full branch diff against origin/main (CI). |
| 7 | +Checks staged changes (local pre-commit), the full branch diff against origin/main (CI PRs), |
| 8 | +and the previous commit (CI merge-queue / push-to-main). |
8 | 9 | """ |
9 | 10 |
|
| 11 | +import os |
10 | 12 | import subprocess |
11 | 13 | import sys |
12 | 14 |
|
13 | 15 | _VERSIONS_PATH = "pyrit/memory/alembic/versions/" |
14 | 16 |
|
15 | 17 |
|
16 | | -def _git(*args: str) -> str: |
17 | | - result = subprocess.run(["git", *args], capture_output=True, text=True) |
18 | | - return result.stdout.strip() |
| 18 | +def _git(*args: str) -> subprocess.CompletedProcess[str]: |
| 19 | + return subprocess.run(["git", *args], capture_output=True, text=True) |
19 | 20 |
|
20 | 21 |
|
21 | | -def _has_non_add_changes(diff_spec: list[str]) -> bool: |
22 | | - output = _git("diff", "--name-status", *diff_spec, "--", _VERSIONS_PATH) |
23 | | - return any(line and not line.startswith("A") for line in output.splitlines()) |
| 22 | +def _git_stdout(*args: str) -> str: |
| 23 | + return _git(*args).stdout.strip() |
| 24 | + |
| 25 | + |
| 26 | +def _get_violations(diff_spec: list[str]) -> list[str]: |
| 27 | + """Return lines from ``git diff --name-status`` that are not pure additions.""" |
| 28 | + output = _git_stdout("diff", "--name-status", *diff_spec, "--", _VERSIONS_PATH) |
| 29 | + return [line for line in output.splitlines() if line and not line.startswith("A")] |
| 30 | + |
| 31 | + |
| 32 | +def _in_ci() -> bool: |
| 33 | + return os.environ.get("CI", "").lower() in {"1", "true"} or "GITHUB_ACTIONS" in os.environ |
| 34 | + |
| 35 | + |
| 36 | +def _fail_ci(reason: str) -> bool: |
| 37 | + """Fail closed in CI when we can't perform the check, pass through locally.""" |
| 38 | + if _in_ci(): |
| 39 | + print(f"[ERROR] Cannot verify alembic revision immutability: {reason}") |
| 40 | + print(" Ensure the CI checkout has full history (fetch-depth: 0).") |
| 41 | + return True |
| 42 | + return False |
24 | 43 |
|
25 | 44 |
|
26 | 45 | def has_revision_violations() -> bool: |
27 | 46 | # Local pre-commit: check staged changes |
28 | | - if _has_non_add_changes(["--cached"]): |
| 47 | + violations = _get_violations(["--cached"]) |
| 48 | + if violations: |
| 49 | + _report(violations) |
29 | 50 | return True |
30 | 51 |
|
31 | | - # CI: check full branch diff against origin/main |
32 | | - merge_base = _git("merge-base", "origin/main", "HEAD") |
33 | | - return bool(merge_base and _has_non_add_changes([f"{merge_base}...HEAD"])) |
| 52 | + # CI (PR): diff branch against its merge-base with origin/main. |
| 53 | + # The three-dot syntax (A...B) resolves to ``git diff $(merge-base A B) B`` |
| 54 | + # automatically, so we don't need a separate merge-base call. When |
| 55 | + # origin/main is missing (shallow clone) git exits non-zero. |
| 56 | + pr_diff = _git("diff", "--name-status", "origin/main...HEAD", "--", _VERSIONS_PATH) |
| 57 | + if pr_diff.returncode == 0: |
| 58 | + violations = [line for line in pr_diff.stdout.strip().splitlines() if line and not line.startswith("A")] |
| 59 | + if violations: |
| 60 | + _report(violations) |
| 61 | + return True |
| 62 | + elif _fail_ci("origin/main is not available (shallow clone?)"): |
| 63 | + return True |
| 64 | + |
| 65 | + # CI (merge-queue / push-to-main): on main the branch *is* origin/main, so |
| 66 | + # the diff above is empty. Compare HEAD against its first parent to catch |
| 67 | + # deletions or modifications introduced by the merge commit itself. |
| 68 | + head_parent = _git("rev-parse", "--verify", "HEAD~1") |
| 69 | + if head_parent.returncode == 0: |
| 70 | + violations = _get_violations(["HEAD~1..HEAD"]) |
| 71 | + if violations: |
| 72 | + _report(violations) |
| 73 | + return True |
| 74 | + elif _fail_ci("HEAD~1 is not available (shallow clone?)"): |
| 75 | + return True |
| 76 | + |
| 77 | + return False |
| 78 | + |
| 79 | + |
| 80 | +def _report(violations: list[str]) -> None: |
| 81 | + print("[ERROR] Migration scripts can only be added, not modified or deleted.") |
| 82 | + print("The following disallowed changes were detected:") |
| 83 | + for v in violations: |
| 84 | + print(f" {v}") |
34 | 85 |
|
35 | 86 |
|
36 | 87 | if __name__ == "__main__": |
37 | 88 | if has_revision_violations(): |
38 | | - print("[ERROR] Migration scripts can only be added, not modified or deleted.") |
39 | 89 | sys.exit(1) |
0 commit comments