tripwire-git 0.2.1__tar.gz → 0.3.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 (37) hide show
  1. {tripwire_git-0.2.1/src/tripwire_git.egg-info → tripwire_git-0.3.0}/PKG-INFO +43 -4
  2. tripwire_git-0.2.1/PKG-INFO → tripwire_git-0.3.0/README.md +211 -191
  3. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/pyproject.toml +1 -1
  4. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/__init__.py +1 -1
  5. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/blocks.py +15 -7
  6. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/cli.py +60 -0
  7. tripwire_git-0.3.0/src/tripwire/codeowners.py +79 -0
  8. tripwire_git-0.3.0/src/tripwire/config.py +153 -0
  9. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/engine.py +23 -12
  10. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/github.py +10 -1
  11. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/review.py +11 -2
  12. tripwire_git-0.3.0/src/tripwire/validate.py +69 -0
  13. tripwire_git-0.2.1/README.md → tripwire_git-0.3.0/src/tripwire_git.egg-info/PKG-INFO +230 -172
  14. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/SOURCES.txt +5 -1
  15. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_github.py +3 -0
  16. tripwire_git-0.3.0/tests/test_groups_and_modes.py +171 -0
  17. tripwire_git-0.3.0/tests/test_validate_codeowners.py +181 -0
  18. tripwire_git-0.2.1/src/tripwire/config.py +0 -82
  19. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/LICENSE +0 -0
  20. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/setup.cfg +0 -0
  21. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/__main__.py +0 -0
  22. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/aiguard.py +0 -0
  23. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/approvals.py +0 -0
  24. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/gitea.py +0 -0
  25. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/gitio.py +0 -0
  26. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/gitlab.py +0 -0
  27. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/httpjson.py +0 -0
  28. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/models.py +0 -0
  29. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/dependency_links.txt +0 -0
  30. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/entry_points.txt +0 -0
  31. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/requires.txt +0 -0
  32. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/top_level.txt +0 -0
  33. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_aiguard.py +0 -0
  34. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_engine.py +0 -0
  35. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_git.py +0 -0
  36. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_gitea.py +0 -0
  37. {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_gitlab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tripwire-git
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Protect blocks of code in Git: changes need approval from an owner, an approver or a leader.
5
5
  License: MIT
6
6
  Keywords: git,code-owners,approval,pre-receive,ci
@@ -71,6 +71,41 @@ required check on GitHub, GitLab and Gitea / Forgejo.
71
71
  Unclosed blocks extend to end of file. A marker whose id is missing from the
72
72
  rules is leaders-only.
73
73
 
74
+ ## Rules file reference
75
+
76
+ ```json
77
+ {
78
+ "whitespace": "ignore",
79
+ "groups": {"finance": ["bob", "carol"], "all": ["@finance", "dave"]},
80
+ "leaders": ["alice", "@org/platform"],
81
+ "locks": [
82
+ {"id": "billing", "owners": ["@finance"], "approvers": ["@all"]},
83
+ {"id": "py-core", "owners": ["bob"], "paths": ["src/core/"], "whitespace": "indent"}
84
+ ]
85
+ }
86
+ ```
87
+
88
+ - `groups`: reusable lists, referenced as `@name` (groups may contain groups).
89
+ - `@org/team`: a platform team. The GitHub adapter resolves it through the API
90
+ (the token needs `read:org`); other adapters leave it unresolved, so it never
91
+ matches anybody. `tripwire validate` warns about this.
92
+ - `whitespace`: `ignore` (default) treats re-indenting as no change; `indent`
93
+ keeps leading whitespace significant, for Python and similar. Set it globally
94
+ or per lock. Trailing whitespace and blank lines never count.
95
+ - `paths`: `dir/` prefix or a glob (`*` also matches `/`).
96
+
97
+ Helpers:
98
+
99
+ ```bash
100
+ tripwire validate # lint the rules against the repository
101
+ tripwire import-codeowners --leader alice --write # start from an existing CODEOWNERS
102
+ ```
103
+
104
+ `import-codeowners` turns every CODEOWNERS pattern into a path lock. CODEOWNERS
105
+ applies "last match wins"; tripwire locks are additive, so it is stricter only
106
+ where a later pattern deliberately narrows an earlier one. Sections and email
107
+ owners are skipped with a warning. Set `leaders` before relying on the result.
108
+
74
109
  ## Commands
75
110
 
76
111
  ```bash
@@ -149,7 +184,8 @@ path lock for the CI configuration, otherwise a PR can delete the check.
149
184
 
150
185
  **GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
151
186
  the PR's current head commit, so a new push voids it. Reviewer logins are
152
- matched to names in `locks.json` case-insensitively; teams are not supported.
187
+ matched to names in `locks.json` case-insensitively; `@org/team` entries are
188
+ resolved through the API.
153
189
 
154
190
  **GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
155
191
  approvals endpoint is not bound to a commit, so enable "Remove all approvals
@@ -176,8 +212,11 @@ reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
176
212
  - On every platform, people who can edit branch protection or CI settings are
177
213
  trusted; tripwire cannot stop an admin from switching the rule off.
178
214
  - Pushes with several authors are checked as one cumulative diff by the pusher.
179
- - Whitespace-insensitive comparison also ignores indentation changes.
180
- - Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
215
+ - By default re-indenting is not a change; set `"whitespace": "indent"` for
216
+ languages where indentation matters.
217
+ - Platform teams (`@org/team`) are resolved on GitHub only (simulated API tests;
218
+ not run against a real organization).
219
+ - Not yet: GitLab groups and Gitea teams, Bitbucket, Azure DevOps, Gerrit.
181
220
 
182
221
  ## Development
183
222
 
@@ -1,191 +1,211 @@
1
- Metadata-Version: 2.4
2
- Name: tripwire-git
3
- Version: 0.2.1
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
- ## AI coding agents (Claude Code)
100
-
101
- Stop an AI agent from changing protected code *before* it touches the disk:
102
-
103
- ```bash
104
- tripwire install-claude-hook # adds a PreToolUse hook to .claude/settings.json
105
- ```
106
-
107
- The agent acts as its own identity (`TRIPWIRE_AI_ACTOR`, default `ai-agent`),
108
- which is not an owner unless you list it. When it tries to `Edit`, `MultiEdit`
109
- or `Write` a protected block, the call is refused with a message naming who may
110
- approve it, and the request is recorded. A human then approves that exact
111
- change **from an interactive terminal**; `grant` shows the request and makes
112
- the person type the approver's name to confirm (a pseudo-terminal alone is not
113
- trusted):
114
-
115
- ```bash
116
- tripwire pending # what was blocked
117
- tripwire grant <id> --approver anna # the agent can now retry the same edit
118
- ```
119
-
120
- The approval is bound to the exact before/after content, so a different edit
121
- is blocked again. To let the agent edit a block freely, list its identity as an
122
- owner of that lock. Rules are read from the committed version (`HEAD`), and the
123
- agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
124
- those files through `Bash`.
125
-
126
- Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
127
- inspected, so keep the pre-commit hook and the server / CI check as backstops.
128
- `grant` trusts whoever runs it in a terminal; a person with shell access can
129
- also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
130
- payloads and end to end with a live Claude Code 2.1 session: the edit was
131
- refused with the message shown to the agent, a human `grant` let the identical
132
- retry through, and the agent's own attempt to run `tripwire grant` did not
133
- approve anything.
134
-
135
- ## Platforms
136
-
137
- | Platform | Enforce with | Approvals from | Verified |
138
- |---|---|---|---|
139
- | Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
140
- | GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
141
- | GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
142
- | Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
143
-
144
- On hosted services, also forbid direct pushes to the protected branch and add a
145
- path lock for the CI configuration, otherwise a PR can delete the check.
146
-
147
- **Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
148
- `TRIPWIRE_ACTOR` from your server's authenticated user.
149
-
150
- **GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
151
- the PR's current head commit, so a new push voids it. Reviewer logins are
152
- matched to names in `locks.json` case-insensitively; teams are not supported.
153
-
154
- **GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
155
- approvals endpoint is not bound to a commit, so enable "Remove all approvals
156
- when commits are added to the source branch"; tripwire cannot verify it.
157
-
158
- **Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
159
- reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
160
- `GITHUB_API_URL` or `GITHUB_SERVER_URL`.
161
-
162
- ## Architecture
163
-
164
- - `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
165
- - `review.py`: shared pull / merge request check used by the platform adapters
166
- - `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
167
- - `approvals.py`: local approvals file
168
- - `gitio.py`, `cli.py`: Git access and command line
169
-
170
- ## Limits
171
-
172
- - Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
173
- trustworthy as whatever sets it. On hosted platforms it is the PR author.
174
- - The local approvals file is trust-based: anyone able to commit could write an
175
- entry naming an approver. Use platform approvals for real deployments.
176
- - On every platform, people who can edit branch protection or CI settings are
177
- trusted; tripwire cannot stop an admin from switching the rule off.
178
- - Pushes with several authors are checked as one cumulative diff by the pusher.
179
- - Whitespace-insensitive comparison also ignores indentation changes.
180
- - Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
181
-
182
- ## Development
183
-
184
- ```bash
185
- pip install -e .[dev]
186
- pytest
187
- ```
188
-
189
- ## License
190
-
191
- MIT
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
+ ## Rules file reference
56
+
57
+ ```json
58
+ {
59
+ "whitespace": "ignore",
60
+ "groups": {"finance": ["bob", "carol"], "all": ["@finance", "dave"]},
61
+ "leaders": ["alice", "@org/platform"],
62
+ "locks": [
63
+ {"id": "billing", "owners": ["@finance"], "approvers": ["@all"]},
64
+ {"id": "py-core", "owners": ["bob"], "paths": ["src/core/"], "whitespace": "indent"}
65
+ ]
66
+ }
67
+ ```
68
+
69
+ - `groups`: reusable lists, referenced as `@name` (groups may contain groups).
70
+ - `@org/team`: a platform team. The GitHub adapter resolves it through the API
71
+ (the token needs `read:org`); other adapters leave it unresolved, so it never
72
+ matches anybody. `tripwire validate` warns about this.
73
+ - `whitespace`: `ignore` (default) treats re-indenting as no change; `indent`
74
+ keeps leading whitespace significant, for Python and similar. Set it globally
75
+ or per lock. Trailing whitespace and blank lines never count.
76
+ - `paths`: `dir/` prefix or a glob (`*` also matches `/`).
77
+
78
+ Helpers:
79
+
80
+ ```bash
81
+ tripwire validate # lint the rules against the repository
82
+ tripwire import-codeowners --leader alice --write # start from an existing CODEOWNERS
83
+ ```
84
+
85
+ `import-codeowners` turns every CODEOWNERS pattern into a path lock. CODEOWNERS
86
+ applies "last match wins"; tripwire locks are additive, so it is stricter only
87
+ where a later pattern deliberately narrows an earlier one. Sections and email
88
+ owners are skipped with a warning. Set `leaders` before relying on the result.
89
+
90
+ ## Commands
91
+
92
+ ```bash
93
+ tripwire preflight --actor alice # uncommitted changes vs HEAD
94
+ tripwire check --base origin/main --head HEAD --actor alice # a commit range
95
+ tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
96
+ tripwire preflight --staged # staged changes only
97
+ tripwire install-hook # local pre-commit hook
98
+ tripwire pre-receive # server hook, reads stdin
99
+ tripwire github-check | gitlab-check | gitea-check # pull / merge requests
100
+ ```
101
+
102
+ Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
103
+
104
+ ## Local early warning
105
+
106
+ `tripwire install-hook` installs a `pre-commit` hook that checks staged changes
107
+ and refuses the commit when a protected block is touched without permission
108
+ (or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
109
+ identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
110
+ `locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
111
+ server-side or CI check below remains the real gate. Nothing can stop someone
112
+ from editing files in their own clone; tripwire stops unapproved changes from
113
+ landing on protected branches.
114
+
115
+ ## AI coding agents (Claude Code)
116
+
117
+ Stop an AI agent from changing protected code *before* it touches the disk:
118
+
119
+ ```bash
120
+ tripwire install-claude-hook # adds a PreToolUse hook to .claude/settings.json
121
+ ```
122
+
123
+ The agent acts as its own identity (`TRIPWIRE_AI_ACTOR`, default `ai-agent`),
124
+ which is not an owner unless you list it. When it tries to `Edit`, `MultiEdit`
125
+ or `Write` a protected block, the call is refused with a message naming who may
126
+ approve it, and the request is recorded. A human then approves that exact
127
+ change **from an interactive terminal**; `grant` shows the request and makes
128
+ the person type the approver's name to confirm (a pseudo-terminal alone is not
129
+ trusted):
130
+
131
+ ```bash
132
+ tripwire pending # what was blocked
133
+ tripwire grant <id> --approver anna # the agent can now retry the same edit
134
+ ```
135
+
136
+ The approval is bound to the exact before/after content, so a different edit
137
+ is blocked again. To let the agent edit a block freely, list its identity as an
138
+ owner of that lock. Rules are read from the committed version (`HEAD`), and the
139
+ agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
140
+ those files through `Bash`.
141
+
142
+ Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
143
+ inspected, so keep the pre-commit hook and the server / CI check as backstops.
144
+ `grant` trusts whoever runs it in a terminal; a person with shell access can
145
+ also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
146
+ payloads and end to end with a live Claude Code 2.1 session: the edit was
147
+ refused with the message shown to the agent, a human `grant` let the identical
148
+ retry through, and the agent's own attempt to run `tripwire grant` did not
149
+ approve anything.
150
+
151
+ ## Platforms
152
+
153
+ | Platform | Enforce with | Approvals from | Verified |
154
+ |---|---|---|---|
155
+ | Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
156
+ | GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
157
+ | GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
158
+ | Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
159
+
160
+ On hosted services, also forbid direct pushes to the protected branch and add a
161
+ path lock for the CI configuration, otherwise a PR can delete the check.
162
+
163
+ **Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
164
+ `TRIPWIRE_ACTOR` from your server's authenticated user.
165
+
166
+ **GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
167
+ the PR's current head commit, so a new push voids it. Reviewer logins are
168
+ matched to names in `locks.json` case-insensitively; `@org/team` entries are
169
+ resolved through the API.
170
+
171
+ **GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
172
+ approvals endpoint is not bound to a commit, so enable "Remove all approvals
173
+ when commits are added to the source branch"; tripwire cannot verify it.
174
+
175
+ **Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
176
+ reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
177
+ `GITHUB_API_URL` or `GITHUB_SERVER_URL`.
178
+
179
+ ## Architecture
180
+
181
+ - `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
182
+ - `review.py`: shared pull / merge request check used by the platform adapters
183
+ - `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
184
+ - `approvals.py`: local approvals file
185
+ - `gitio.py`, `cli.py`: Git access and command line
186
+
187
+ ## Limits
188
+
189
+ - Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
190
+ trustworthy as whatever sets it. On hosted platforms it is the PR author.
191
+ - The local approvals file is trust-based: anyone able to commit could write an
192
+ entry naming an approver. Use platform approvals for real deployments.
193
+ - On every platform, people who can edit branch protection or CI settings are
194
+ trusted; tripwire cannot stop an admin from switching the rule off.
195
+ - Pushes with several authors are checked as one cumulative diff by the pusher.
196
+ - By default re-indenting is not a change; set `"whitespace": "indent"` for
197
+ languages where indentation matters.
198
+ - Platform teams (`@org/team`) are resolved on GitHub only (simulated API tests;
199
+ not run against a real organization).
200
+ - Not yet: GitLab groups and Gitea teams, Bitbucket, Azure DevOps, Gerrit.
201
+
202
+ ## Development
203
+
204
+ ```bash
205
+ pip install -e .[dev]
206
+ pytest
207
+ ```
208
+
209
+ ## License
210
+
211
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tripwire-git"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Protect blocks of code in Git: changes need approval from an owner, an approver or a leader."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """tripwire: approval-gated protection for blocks of code in Git."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
@@ -9,12 +9,20 @@ END = re.compile(r"@lock-end\s+([A-Za-z0-9_.\-]+)")
9
9
  Norm = Tuple[str, ...]
10
10
 
11
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())
12
+ def _clean(line: str, mode: str) -> str:
13
+ return line.rstrip() if mode == "indent" else line.strip()
15
14
 
16
15
 
17
- def extract_blocks(text: str) -> Dict[str, Norm]:
16
+ def normalize(text: str, mode: str = "ignore") -> Norm:
17
+ """Drop blank lines and trailing whitespace.
18
+
19
+ mode "ignore" also strips leading whitespace, so re-indenting is not a change;
20
+ mode "indent" keeps it (for languages where indentation carries meaning).
21
+ """
22
+ return tuple(c for c in (_clean(line, mode) for line in text.splitlines()) if c.strip())
23
+
24
+
25
+ def extract_blocks(text: str, mode: str = "ignore") -> Dict[str, Norm]:
18
26
  """Map block id -> normalized content between its markers.
19
27
 
20
28
  An unclosed block extends to the end of the file (fail closed).
@@ -34,8 +42,8 @@ def extract_blocks(text: str) -> Dict[str, Norm]:
34
42
  open_ids.remove(end.group(1))
35
43
  open_ids.reverse()
36
44
  continue
37
- stripped = line.strip()
38
- if stripped:
45
+ cleaned = _clean(line, mode)
46
+ if cleaned.strip():
39
47
  for block_id in open_ids:
40
- content[block_id].append(stripped)
48
+ content[block_id].append(cleaned)
41
49
  return {k: tuple(v) for k, v in content.items()}
@@ -243,6 +243,55 @@ def cmd_install_claude_hook(args, root: str) -> int:
243
243
  return 0
244
244
 
245
245
 
246
+ def cmd_validate(args, root: str) -> int:
247
+ from .validate import validate
248
+
249
+ path = args.file or os.path.join(root, RULES_PATH)
250
+ if not os.path.isfile(path):
251
+ print("tripwire: %s not found" % path, file=sys.stderr)
252
+ return 2
253
+ with open(path, encoding="utf-8") as fh:
254
+ errors, warnings = validate(root, fh.read())
255
+ for e in errors:
256
+ print("error: " + e, file=sys.stderr)
257
+ for w in warnings:
258
+ print("warning: " + w)
259
+ print("tripwire: %d error(s), %d warning(s)" % (len(errors), len(warnings)))
260
+ return 1 if errors else 0
261
+
262
+
263
+ def cmd_import_codeowners(args, root: str) -> int:
264
+ from . import codeowners
265
+
266
+ source = args.file
267
+ if source is None:
268
+ for cand in codeowners.CANDIDATES:
269
+ if os.path.isfile(os.path.join(root, cand)):
270
+ source = os.path.join(root, cand)
271
+ break
272
+ if source is None or not os.path.isfile(source):
273
+ print("tripwire: no CODEOWNERS file found; pass --file", file=sys.stderr)
274
+ return 2
275
+ with open(source, encoding="utf-8") as fh:
276
+ entries, warnings = codeowners.parse(fh.read())
277
+ config = codeowners.to_config(entries, args.leader or [])
278
+ for w in warnings:
279
+ print("warning: " + w, file=sys.stderr)
280
+ text = json.dumps(config, indent=2) + "\n"
281
+ if not args.write:
282
+ sys.stdout.write(text)
283
+ return 0
284
+ target = os.path.join(root, RULES_PATH)
285
+ if os.path.exists(target) and not args.force:
286
+ print("tripwire: %s already exists; use --force to replace it" % RULES_PATH, file=sys.stderr)
287
+ return 1
288
+ os.makedirs(os.path.dirname(target), exist_ok=True)
289
+ with open(target, "w", encoding="utf-8") as fh:
290
+ fh.write(text)
291
+ print("tripwire: wrote %d lock(s) to %s; set leaders before relying on it" % (len(config["locks"]), RULES_PATH))
292
+ return 0
293
+
294
+
246
295
  def cmd_approve(args, root: str) -> int:
247
296
  config, decision = run_check(root, args.base, args.head, actor="")
248
297
  if config is None:
@@ -322,6 +371,17 @@ def build_parser() -> argparse.ArgumentParser:
322
371
  gr.add_argument("--approver", required=True)
323
372
  gr.set_defaults(func=cmd_grant)
324
373
 
374
+ v = sub.add_parser("validate", help="check the rules file against the repository")
375
+ v.add_argument("--file", help="rules file (default: .tripwire/locks.json)")
376
+ v.set_defaults(func=cmd_validate)
377
+
378
+ ic = sub.add_parser("import-codeowners", help="turn a CODEOWNERS file into path locks")
379
+ ic.add_argument("--file", help="CODEOWNERS path (default: search the usual places)")
380
+ ic.add_argument("--leader", action="append", help="leader name (repeatable)")
381
+ ic.add_argument("--write", action="store_true", help="write .tripwire/locks.json instead of printing")
382
+ ic.add_argument("--force", action="store_true", help="overwrite an existing rules file")
383
+ ic.set_defaults(func=cmd_import_codeowners)
384
+
325
385
  a = sub.add_parser("approve", help="record an approval for the pending change")
326
386
  a.add_argument("--base", required=True)
327
387
  a.add_argument("--head")