tripwire-git 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. tripwire_git-0.1.0/LICENSE +21 -0
  2. tripwire_git-0.1.0/PKG-INFO +155 -0
  3. tripwire_git-0.1.0/README.md +136 -0
  4. tripwire_git-0.1.0/pyproject.toml +34 -0
  5. tripwire_git-0.1.0/setup.cfg +4 -0
  6. tripwire_git-0.1.0/src/tripwire/__init__.py +3 -0
  7. tripwire_git-0.1.0/src/tripwire/__main__.py +5 -0
  8. tripwire_git-0.1.0/src/tripwire/approvals.py +30 -0
  9. tripwire_git-0.1.0/src/tripwire/blocks.py +41 -0
  10. tripwire_git-0.1.0/src/tripwire/cli.py +244 -0
  11. tripwire_git-0.1.0/src/tripwire/config.py +82 -0
  12. tripwire_git-0.1.0/src/tripwire/engine.py +77 -0
  13. tripwire_git-0.1.0/src/tripwire/gitea.py +71 -0
  14. tripwire_git-0.1.0/src/tripwire/github.py +64 -0
  15. tripwire_git-0.1.0/src/tripwire/gitio.py +76 -0
  16. tripwire_git-0.1.0/src/tripwire/gitlab.py +66 -0
  17. tripwire_git-0.1.0/src/tripwire/httpjson.py +34 -0
  18. tripwire_git-0.1.0/src/tripwire/models.py +35 -0
  19. tripwire_git-0.1.0/src/tripwire/review.py +33 -0
  20. tripwire_git-0.1.0/src/tripwire_git.egg-info/PKG-INFO +155 -0
  21. tripwire_git-0.1.0/src/tripwire_git.egg-info/SOURCES.txt +28 -0
  22. tripwire_git-0.1.0/src/tripwire_git.egg-info/dependency_links.txt +1 -0
  23. tripwire_git-0.1.0/src/tripwire_git.egg-info/entry_points.txt +2 -0
  24. tripwire_git-0.1.0/src/tripwire_git.egg-info/requires.txt +3 -0
  25. tripwire_git-0.1.0/src/tripwire_git.egg-info/top_level.txt +1 -0
  26. tripwire_git-0.1.0/tests/test_engine.py +111 -0
  27. tripwire_git-0.1.0/tests/test_git.py +153 -0
  28. tripwire_git-0.1.0/tests/test_gitea.py +95 -0
  29. tripwire_git-0.1.0/tests/test_github.py +122 -0
  30. tripwire_git-0.1.0/tests/test_gitlab.py +99 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 codelock contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: tripwire-git
3
+ Version: 0.1.0
4
+ Summary: Protect blocks of code in Git: changes need approval from an owner, an approver or a leader.
5
+ License: MIT
6
+ Keywords: git,code-owners,approval,pre-receive,ci
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Version Control :: Git
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # tripwire
21
+
22
+ Protect blocks of code in Git. A protected block can only be changed by its
23
+ owner or a leader, or by someone whose change an authorized person approved.
24
+ Works from a local pre-commit check, a self-hosted `pre-receive` hook, or as a
25
+ required check on GitHub, GitLab and Gitea / Forgejo.
26
+
27
+ ## Quick start
28
+
29
+ 1. Mark a block in any language, inside a comment:
30
+
31
+ ```python
32
+ # @lock-begin billing
33
+ RATE = 5
34
+ # @lock-end billing
35
+ ```
36
+
37
+ 2. Create `.tripwire/locks.json` (see `locks.example.json`):
38
+
39
+ ```json
40
+ {
41
+ "leaders": ["alice"],
42
+ "locks": [
43
+ {"id": "billing", "owners": ["bob"], "approvers": ["carol"]},
44
+ {"id": "prod-config", "owners": ["bob"], "paths": ["config/prod/", "*.pem"]}
45
+ ]
46
+ }
47
+ ```
48
+
49
+ 3. Check a change:
50
+
51
+ ```bash
52
+ pip install tripwire-git
53
+ tripwire preflight --actor dave # uncommitted changes vs HEAD
54
+ ```
55
+
56
+ Output: `tripwire: BLOCKED` with `block 'billing' in app.py is locked; ask:
57
+ alice, bob, carol`, exit code 1.
58
+
59
+ ## Concepts
60
+
61
+ - Blocks are marked with `@lock-begin <id>` / `@lock-end <id>`; files and
62
+ folders are locked with path patterns (`dir/` prefix or glob).
63
+ - Owners and leaders may edit freely. Anyone else needs an approval from an
64
+ owner, an approver or a leader. You cannot approve your own change.
65
+ - Approvals are bound to the exact before/after content of the protected
66
+ block (a SHA-256 digest). Edit the code again and the approval is void.
67
+ - Rules are always read from the **base** revision (the target branch), so a
68
+ change cannot rewrite the rules that judge it. Changing the rules file is
69
+ leaders-only and cannot be approved.
70
+ - Whitespace-only edits are ignored. Deleting markers counts as a change.
71
+ Unclosed blocks extend to end of file. A marker whose id is missing from the
72
+ rules is leaders-only.
73
+
74
+ ## Commands
75
+
76
+ ```bash
77
+ tripwire preflight --actor alice # uncommitted changes vs HEAD
78
+ tripwire check --base origin/main --head HEAD --actor alice # a commit range
79
+ tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
80
+ tripwire preflight --staged # staged changes only
81
+ tripwire install-hook # local pre-commit hook
82
+ tripwire pre-receive # server hook, reads stdin
83
+ tripwire github-check | gitlab-check | gitea-check # pull / merge requests
84
+ ```
85
+
86
+ Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
87
+
88
+ ## Local early warning
89
+
90
+ `tripwire install-hook` installs a `pre-commit` hook that checks staged changes
91
+ and refuses the commit when a protected block is touched without permission
92
+ (or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
93
+ identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
94
+ `locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
95
+ server-side or CI check below remains the real gate. Nothing can stop someone
96
+ from editing files in their own clone; tripwire stops unapproved changes from
97
+ landing on protected branches.
98
+
99
+ ## Platforms
100
+
101
+ | Platform | Enforce with | Approvals from | Verified |
102
+ |---|---|---|---|
103
+ | Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
104
+ | GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
105
+ | GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
106
+ | Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
107
+
108
+ On hosted services, also forbid direct pushes to the protected branch and add a
109
+ path lock for the CI configuration, otherwise a PR can delete the check.
110
+
111
+ **Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
112
+ `TRIPWIRE_ACTOR` from your server's authenticated user.
113
+
114
+ **GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
115
+ the PR's current head commit, so a new push voids it. Reviewer logins are
116
+ matched to names in `locks.json` case-insensitively; teams are not supported.
117
+
118
+ **GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
119
+ approvals endpoint is not bound to a commit, so enable "Remove all approvals
120
+ when commits are added to the source branch"; tripwire cannot verify it.
121
+
122
+ **Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
123
+ reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
124
+ `GITHUB_API_URL` or `GITHUB_SERVER_URL`.
125
+
126
+ ## Architecture
127
+
128
+ - `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
129
+ - `review.py`: shared pull / merge request check used by the platform adapters
130
+ - `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
131
+ - `approvals.py`: local approvals file
132
+ - `gitio.py`, `cli.py`: Git access and command line
133
+
134
+ ## Limits
135
+
136
+ - Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
137
+ trustworthy as whatever sets it. On hosted platforms it is the PR author.
138
+ - The local approvals file is trust-based: anyone able to commit could write an
139
+ entry naming an approver. Use platform approvals for real deployments.
140
+ - On every platform, people who can edit branch protection or CI settings are
141
+ trusted; tripwire cannot stop an admin from switching the rule off.
142
+ - Pushes with several authors are checked as one cumulative diff by the pusher.
143
+ - Whitespace-insensitive comparison also ignores indentation changes.
144
+ - Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ pip install -e .[dev]
150
+ pytest
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,136 @@
1
+ # tripwire
2
+
3
+ Protect blocks of code in Git. A protected block can only be changed by its
4
+ owner or a leader, or by someone whose change an authorized person approved.
5
+ Works from a local pre-commit check, a self-hosted `pre-receive` hook, or as a
6
+ required check on GitHub, GitLab and Gitea / Forgejo.
7
+
8
+ ## Quick start
9
+
10
+ 1. Mark a block in any language, inside a comment:
11
+
12
+ ```python
13
+ # @lock-begin billing
14
+ RATE = 5
15
+ # @lock-end billing
16
+ ```
17
+
18
+ 2. Create `.tripwire/locks.json` (see `locks.example.json`):
19
+
20
+ ```json
21
+ {
22
+ "leaders": ["alice"],
23
+ "locks": [
24
+ {"id": "billing", "owners": ["bob"], "approvers": ["carol"]},
25
+ {"id": "prod-config", "owners": ["bob"], "paths": ["config/prod/", "*.pem"]}
26
+ ]
27
+ }
28
+ ```
29
+
30
+ 3. Check a change:
31
+
32
+ ```bash
33
+ pip install tripwire-git
34
+ tripwire preflight --actor dave # uncommitted changes vs HEAD
35
+ ```
36
+
37
+ Output: `tripwire: BLOCKED` with `block 'billing' in app.py is locked; ask:
38
+ alice, bob, carol`, exit code 1.
39
+
40
+ ## Concepts
41
+
42
+ - Blocks are marked with `@lock-begin <id>` / `@lock-end <id>`; files and
43
+ folders are locked with path patterns (`dir/` prefix or glob).
44
+ - Owners and leaders may edit freely. Anyone else needs an approval from an
45
+ owner, an approver or a leader. You cannot approve your own change.
46
+ - Approvals are bound to the exact before/after content of the protected
47
+ block (a SHA-256 digest). Edit the code again and the approval is void.
48
+ - Rules are always read from the **base** revision (the target branch), so a
49
+ change cannot rewrite the rules that judge it. Changing the rules file is
50
+ leaders-only and cannot be approved.
51
+ - Whitespace-only edits are ignored. Deleting markers counts as a change.
52
+ Unclosed blocks extend to end of file. A marker whose id is missing from the
53
+ rules is leaders-only.
54
+
55
+ ## Commands
56
+
57
+ ```bash
58
+ tripwire preflight --actor alice # uncommitted changes vs HEAD
59
+ tripwire check --base origin/main --head HEAD --actor alice # a commit range
60
+ tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
61
+ tripwire preflight --staged # staged changes only
62
+ tripwire install-hook # local pre-commit hook
63
+ tripwire pre-receive # server hook, reads stdin
64
+ tripwire github-check | gitlab-check | gitea-check # pull / merge requests
65
+ ```
66
+
67
+ Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
68
+
69
+ ## Local early warning
70
+
71
+ `tripwire install-hook` installs a `pre-commit` hook that checks staged changes
72
+ and refuses the commit when a protected block is touched without permission
73
+ (or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
74
+ identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
75
+ `locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
76
+ server-side or CI check below remains the real gate. Nothing can stop someone
77
+ from editing files in their own clone; tripwire stops unapproved changes from
78
+ landing on protected branches.
79
+
80
+ ## Platforms
81
+
82
+ | Platform | Enforce with | Approvals from | Verified |
83
+ |---|---|---|---|
84
+ | Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
85
+ | GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
86
+ | GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
87
+ | Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
88
+
89
+ On hosted services, also forbid direct pushes to the protected branch and add a
90
+ path lock for the CI configuration, otherwise a PR can delete the check.
91
+
92
+ **Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
93
+ `TRIPWIRE_ACTOR` from your server's authenticated user.
94
+
95
+ **GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
96
+ the PR's current head commit, so a new push voids it. Reviewer logins are
97
+ matched to names in `locks.json` case-insensitively; teams are not supported.
98
+
99
+ **GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
100
+ approvals endpoint is not bound to a commit, so enable "Remove all approvals
101
+ when commits are added to the source branch"; tripwire cannot verify it.
102
+
103
+ **Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
104
+ reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
105
+ `GITHUB_API_URL` or `GITHUB_SERVER_URL`.
106
+
107
+ ## Architecture
108
+
109
+ - `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
110
+ - `review.py`: shared pull / merge request check used by the platform adapters
111
+ - `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
112
+ - `approvals.py`: local approvals file
113
+ - `gitio.py`, `cli.py`: Git access and command line
114
+
115
+ ## Limits
116
+
117
+ - Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
118
+ trustworthy as whatever sets it. On hosted platforms it is the PR author.
119
+ - The local approvals file is trust-based: anyone able to commit could write an
120
+ entry naming an approver. Use platform approvals for real deployments.
121
+ - On every platform, people who can edit branch protection or CI settings are
122
+ trusted; tripwire cannot stop an admin from switching the rule off.
123
+ - Pushes with several authors are checked as one cumulative diff by the pusher.
124
+ - Whitespace-insensitive comparison also ignores indentation changes.
125
+ - Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ pip install -e .[dev]
131
+ pytest
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tripwire-git"
7
+ version = "0.1.0"
8
+ description = "Protect blocks of code in Git: changes need approval from an owner, an approver or a leader."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["git", "code-owners", "approval", "pre-receive", "ci"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Version Control :: Git",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest"]
25
+
26
+ [project.scripts]
27
+ tripwire = "tripwire.cli:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["src"]
34
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """tripwire: approval-gated protection for blocks of code in Git."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,30 @@
1
+ """Approval providers. Local provider reads .tripwire/approvals.json.
2
+
3
+ Platform providers (GitHub, GitLab, ...) will implement the same shape:
4
+ return a list of Approval for the change being checked.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import List, Optional
11
+
12
+ from .config import ConfigError
13
+ from .models import Approval
14
+
15
+
16
+ def parse_local(text: Optional[str]) -> List[Approval]:
17
+ if not text or not text.strip():
18
+ return []
19
+ try:
20
+ raw = json.loads(text)
21
+ return [Approval(i["lock"], i["approver"], i["digest"]) for i in raw]
22
+ except (ValueError, KeyError, TypeError) as exc:
23
+ raise ConfigError("invalid approvals file: %s" % exc)
24
+
25
+
26
+ def dump_local(approvals: List[Approval]) -> str:
27
+ return json.dumps(
28
+ [{"lock": a.lock_id, "approver": a.approver, "digest": a.digest} for a in approvals],
29
+ indent=2,
30
+ ) + "\n"
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Dict, List, Tuple
5
+
6
+ BEGIN = re.compile(r"@lock-begin\s+([A-Za-z0-9_.\-]+)")
7
+ END = re.compile(r"@lock-end\s+([A-Za-z0-9_.\-]+)")
8
+
9
+ Norm = Tuple[str, ...]
10
+
11
+
12
+ def normalize(text: str) -> Norm:
13
+ """Strip each line and drop blank lines, so whitespace-only edits don't count."""
14
+ return tuple(line.strip() for line in text.splitlines() if line.strip())
15
+
16
+
17
+ def extract_blocks(text: str) -> Dict[str, Norm]:
18
+ """Map block id -> normalized content between its markers.
19
+
20
+ An unclosed block extends to the end of the file (fail closed).
21
+ """
22
+ content: Dict[str, List[str]] = {}
23
+ open_ids: List[str] = []
24
+ for line in text.splitlines():
25
+ begin = BEGIN.search(line)
26
+ if begin:
27
+ open_ids.append(begin.group(1))
28
+ content.setdefault(begin.group(1), [])
29
+ continue
30
+ end = END.search(line)
31
+ if end:
32
+ if end.group(1) in open_ids:
33
+ open_ids.reverse()
34
+ open_ids.remove(end.group(1))
35
+ open_ids.reverse()
36
+ continue
37
+ stripped = line.strip()
38
+ if stripped:
39
+ for block_id in open_ids:
40
+ content[block_id].append(stripped)
41
+ return {k: tuple(v) for k, v in content.items()}
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from typing import List, Optional, Tuple
8
+
9
+ from .approvals import dump_local, parse_local
10
+ from .config import APPROVALS_PATH, RULES_PATH, Config, ConfigError, parse_config
11
+ from .engine import evaluate
12
+ from .gitio import (
13
+ EMPTY_TREE,
14
+ ZERO,
15
+ GitError,
16
+ RevSource,
17
+ WorktreeSource,
18
+ collect_changes,
19
+ git,
20
+ new_branch_base,
21
+ toplevel,
22
+ )
23
+ from .models import Approval, Decision
24
+
25
+
26
+ def resolve_actor(explicit: Optional[str], root: str) -> str:
27
+ for candidate in (
28
+ explicit,
29
+ os.environ.get("TRIPWIRE_ACTOR"),
30
+ os.environ.get("GL_USERNAME"),
31
+ os.environ.get("REMOTE_USER"),
32
+ ):
33
+ if candidate:
34
+ return candidate
35
+ for key in ("tripwire.actor", "user.email"):
36
+ value = git(root, "config", key, check=False).decode().strip()
37
+ if value:
38
+ return value
39
+ raise ConfigError("cannot determine the acting user; pass --actor or set TRIPWIRE_ACTOR")
40
+
41
+
42
+ def run_check(root: str, base: str, head: Optional[str], actor: str) -> Tuple[Optional[Config], Decision]:
43
+ """Rules are read from `base`, so a change cannot rewrite the rules that judge it."""
44
+ rules = RevSource(root, base).read(RULES_PATH)
45
+ if rules is None:
46
+ return None, Decision()
47
+ config = parse_config(rules)
48
+ head_src = RevSource(root, head) if head is not None else WorktreeSource(root)
49
+ approvals = parse_local(head_src.read(APPROVALS_PATH))
50
+ return config, evaluate(config, collect_changes(root, base, head), actor, approvals)
51
+
52
+
53
+ def report(decision: Decision) -> int:
54
+ if decision.allowed:
55
+ print("tripwire: OK")
56
+ return 0
57
+ print("tripwire: BLOCKED", file=sys.stderr)
58
+ for v in decision.violations:
59
+ print(" - " + v.message, file=sys.stderr)
60
+ return 1
61
+
62
+
63
+ def cmd_check(args, root: str) -> int:
64
+ actor = resolve_actor(args.actor, root)
65
+ _, decision = run_check(root, args.base, args.head, actor)
66
+ return report(decision)
67
+
68
+
69
+ def cmd_preflight(args, root: str) -> int:
70
+ actor = resolve_actor(args.actor, root)
71
+ has_head = git(root, "rev-parse", "--verify", "-q", "HEAD", check=False).strip()
72
+ _, decision = run_check(root, "HEAD" if has_head else EMPTY_TREE, "" if args.staged else None, actor)
73
+ return report(decision)
74
+
75
+
76
+ HOOK_MARK = "# installed by tripwire"
77
+ HOOK = """#!/bin/sh
78
+ %s
79
+ # Local early warning. Bypassable with --no-verify: the server-side check is the real gate.
80
+ if command -v tripwire >/dev/null 2>&1; then
81
+ exec tripwire preflight --staged
82
+ else
83
+ exec python -m tripwire preflight --staged
84
+ fi
85
+ """ % HOOK_MARK
86
+
87
+
88
+ def cmd_install_hook(args, root: str) -> int:
89
+ hooks = git(root, "rev-parse", "--git-path", "hooks").decode().strip()
90
+ path = os.path.join(root, hooks, "pre-commit") if not os.path.isabs(hooks) else os.path.join(hooks, "pre-commit")
91
+ if os.path.exists(path):
92
+ with open(path, encoding="utf-8", errors="replace") as fh:
93
+ ours = HOOK_MARK in fh.read()
94
+ if not ours and not args.force:
95
+ print("tripwire: %s already exists; use --force to replace it" % path, file=sys.stderr)
96
+ return 1
97
+ os.makedirs(os.path.dirname(path), exist_ok=True)
98
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
99
+ fh.write(HOOK)
100
+ os.chmod(path, 0o755)
101
+ print("tripwire: installed %s" % path)
102
+ return 0
103
+
104
+
105
+ def cmd_pre_receive(args, root: str) -> int:
106
+ actor = resolve_actor(args.actor, root)
107
+ status = 0
108
+ for line in sys.stdin:
109
+ parts = line.split()
110
+ if len(parts) < 3:
111
+ continue
112
+ old, new, ref = parts[:3]
113
+ if new == ZERO:
114
+ continue
115
+ base = old if old != ZERO else new_branch_base(root, new)
116
+ if base is None:
117
+ continue
118
+ _, decision = run_check(root, base, new, actor)
119
+ if not decision.allowed:
120
+ print("tripwire: push to %s rejected" % ref, file=sys.stderr)
121
+ status = report(decision) or status
122
+ return status
123
+
124
+
125
+ def _pull_number(explicit: Optional[int]) -> int:
126
+ if explicit is not None:
127
+ return explicit
128
+ event = os.environ.get("GITHUB_EVENT_PATH")
129
+ if not event or not os.path.isfile(event):
130
+ raise ConfigError("pull request number unknown; pass --pr")
131
+ with open(event, encoding="utf-8") as fh:
132
+ number = json.load(fh).get("pull_request", {}).get("number")
133
+ if number is None:
134
+ raise ConfigError("not a pull_request event; pass --pr")
135
+ return int(number)
136
+
137
+
138
+ def cmd_github_check(args, root: str) -> int:
139
+ from .github import GitHubClient, check_pull_request
140
+
141
+ return report(check_pull_request(root, GitHubClient.from_env(args.github_repo), _pull_number(args.pr)))
142
+
143
+
144
+ def cmd_gitea_check(args, root: str) -> int:
145
+ from .gitea import GiteaClient, check_pull_request
146
+
147
+ client = GiteaClient.from_env(args.gitea_repo, args.gitea_url)
148
+ return report(check_pull_request(root, client, _pull_number(args.pr)))
149
+
150
+
151
+ def cmd_gitlab_check(args, root: str) -> int:
152
+ from .gitlab import GitLabClient, check_merge_request
153
+
154
+ iid = args.mr or os.environ.get("CI_MERGE_REQUEST_IID")
155
+ if not iid:
156
+ raise ConfigError("merge request number unknown; pass --mr or run in a merge request pipeline")
157
+ return report(check_merge_request(root, GitLabClient.from_env(args.gitlab_project), int(iid)))
158
+
159
+
160
+ def cmd_approve(args, root: str) -> int:
161
+ config, decision = run_check(root, args.base, args.head, actor="")
162
+ if config is None:
163
+ print("tripwire: no rules file at base", file=sys.stderr)
164
+ return 2
165
+ if args.approver not in config.everyone():
166
+ print("tripwire: %s is not an owner, approver or leader" % args.approver, file=sys.stderr)
167
+ return 1
168
+ path = os.path.join(root, APPROVALS_PATH)
169
+ existing: List[Approval] = []
170
+ if os.path.isfile(path):
171
+ with open(path, encoding="utf-8") as fh:
172
+ existing = parse_local(fh.read())
173
+ added = 0
174
+ for v in decision.violations:
175
+ if v.digest and (args.lock is None or v.lock_id == args.lock):
176
+ lock = config.get(v.lock_id)
177
+ allowed = set(config.leaders) | (set(lock.owners) | set(lock.approvers) if lock else set())
178
+ if args.approver in allowed:
179
+ existing.append(Approval(v.lock_id, args.approver, v.digest))
180
+ added += 1
181
+ os.makedirs(os.path.dirname(path), exist_ok=True)
182
+ with open(path, "w", encoding="utf-8") as fh:
183
+ fh.write(dump_local(existing))
184
+ print("tripwire: recorded %d approval(s) in %s" % (added, APPROVALS_PATH))
185
+ return 0 if added else 1
186
+
187
+
188
+ def build_parser() -> argparse.ArgumentParser:
189
+ p = argparse.ArgumentParser(prog="tripwire")
190
+ p.add_argument("--repo", default=".", help="path inside the repository")
191
+ sub = p.add_subparsers(dest="command", required=True)
192
+
193
+ c = sub.add_parser("check", help="check base..head (head defaults to the working tree)")
194
+ c.add_argument("--base", required=True)
195
+ c.add_argument("--head")
196
+ c.add_argument("--actor")
197
+ c.set_defaults(func=cmd_check)
198
+
199
+ f = sub.add_parser("preflight", help="check uncommitted changes against HEAD")
200
+ f.add_argument("--actor")
201
+ f.add_argument("--staged", action="store_true", help="check only staged changes (for pre-commit)")
202
+ f.set_defaults(func=cmd_preflight)
203
+
204
+ h = sub.add_parser("install-hook", help="install a local pre-commit hook")
205
+ h.add_argument("--force", action="store_true", help="replace an existing pre-commit hook")
206
+ h.set_defaults(func=cmd_install_hook)
207
+
208
+ r = sub.add_parser("pre-receive", help="server hook: read ref updates from stdin")
209
+ r.add_argument("--actor")
210
+ r.set_defaults(func=cmd_pre_receive)
211
+
212
+ g = sub.add_parser("github-check", help="check a GitHub pull request using its reviews")
213
+ g.add_argument("--pr", type=int, help="PR number (default: from GITHUB_EVENT_PATH)")
214
+ g.add_argument("--github-repo", help="owner/name (default: GITHUB_REPOSITORY)")
215
+ g.set_defaults(func=cmd_github_check)
216
+
217
+ gt = sub.add_parser("gitea-check", help="check a Gitea/Forgejo pull request using its reviews")
218
+ gt.add_argument("--pr", type=int, help="PR number (default: from GITHUB_EVENT_PATH)")
219
+ gt.add_argument("--gitea-repo", help="owner/name (default: GITHUB_REPOSITORY)")
220
+ gt.add_argument("--gitea-url", help="API base, e.g. https://git.example.com/api/v1")
221
+ gt.set_defaults(func=cmd_gitea_check)
222
+
223
+ gl = sub.add_parser("gitlab-check", help="check a GitLab merge request using its approvals")
224
+ gl.add_argument("--mr", type=int, help="MR iid (default: CI_MERGE_REQUEST_IID)")
225
+ gl.add_argument("--gitlab-project", help="project id or path (default: CI_PROJECT_ID)")
226
+ gl.set_defaults(func=cmd_gitlab_check)
227
+
228
+ a = sub.add_parser("approve", help="record an approval for the pending change")
229
+ a.add_argument("--base", required=True)
230
+ a.add_argument("--head")
231
+ a.add_argument("--approver", required=True)
232
+ a.add_argument("--lock", help="only approve this lock id")
233
+ a.set_defaults(func=cmd_approve)
234
+ return p
235
+
236
+
237
+ def main(argv: Optional[List[str]] = None) -> int:
238
+ args = build_parser().parse_args(argv)
239
+ try:
240
+ root = toplevel(args.repo) if args.command != "pre-receive" else os.path.abspath(args.repo)
241
+ return args.func(args, root)
242
+ except (ConfigError, GitError) as exc:
243
+ print("tripwire: error: %s" % exc, file=sys.stderr)
244
+ return 2