commitguardian 0.1.0__py3-none-any.whl

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 (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,527 @@
1
+ """Git hook installation, removal and integrity checking.
2
+
3
+ Installed hooks are tiny POSIX ``sh`` wrappers - they contain no detection or
4
+ policy logic. Each wrapper consists of a *managed block*::
5
+
6
+ #!/bin/sh
7
+ # BEGIN COMMITGUARD
8
+ # commitguard-hook: pre-push
9
+ # commitguard-format: 1
10
+ # commitguard-checksum: sha256:<hex>
11
+ ...runs `python -P -m commitguard hook pre-push "$@"`, then the chained hook...
12
+ # END COMMITGUARD
13
+
14
+ Execution order inside a wrapper:
15
+
16
+ 1. CommitGuard (``commitguard hook <name>``). A non-zero exit stops Git.
17
+ 2. The *chained* hook ``<name>.pre-commitguard`` - the hook that existed
18
+ before installation - with the same arguments and standard input. Its exit
19
+ status is respected.
20
+
21
+ Safety properties:
22
+
23
+ * an existing, non-CommitGuard hook is never overwritten or deleted: it is
24
+ atomically renamed to ``<name>.pre-commitguard`` and chained, and
25
+ ``uninstall`` renames it back;
26
+ * ``uninstall`` removes only the managed block, never unrelated content;
27
+ * hooks are only installed inside the repository's own Git directory unless
28
+ explicitly allowed, because a shared ``core.hooksPath`` affects other
29
+ repositories;
30
+ * the wrapper runs Python with ``-P`` so a ``commitguard/`` directory in the
31
+ repository being committed cannot shadow the real package;
32
+ * the block carries a checksum so ``commitguard doctor`` can detect edits;
33
+ * the Python interpreter that ran ``commitguard install`` is embedded (the
34
+ only reliable way to find a virtualenv install when Git runs the hook from
35
+ a different shell); if it disappears the wrapper falls back to
36
+ ``commitguard`` on ``PATH`` and otherwise fails closed.
37
+ """
38
+
39
+ import hashlib
40
+ import os
41
+ import sys
42
+ from enum import StrEnum
43
+ from pathlib import Path
44
+
45
+ from pydantic import BaseModel, ConfigDict
46
+
47
+ from commitguard.config.loader import global_config_path
48
+ from commitguard.exceptions.git import GitCommandError, HookInstallError
49
+ from commitguard.git.commands import run_git
50
+ from commitguard.git.repository import Repository
51
+ from commitguard.utils.filesystem import atomic_write_text, read_text_limited
52
+ from commitguard.utils.platform import path_for_posix_shell, supports_executable_bit
53
+
54
+ BEGIN_MARKER = "# BEGIN COMMITGUARD"
55
+ END_MARKER = "# END COMMITGUARD"
56
+ FORMAT_VERSION = 1
57
+ CHAINED_SUFFIX = ".pre-commitguard"
58
+ SHEBANG = "#!/bin/sh"
59
+ MAX_HOOK_BYTES = 1024 * 1024
60
+ _CHECKSUM_PREFIX = "# commitguard-checksum: sha256:"
61
+ _PYTHON_PREFIX = "commitguard_python="
62
+
63
+
64
+ class HookType(StrEnum):
65
+ """Git hooks CommitGuard manages."""
66
+
67
+ PRE_COMMIT = "pre-commit"
68
+ COMMIT_MSG = "commit-msg"
69
+ PRE_PUSH = "pre-push"
70
+
71
+
72
+ # --------------------------------------------------------------------------- #
73
+ # Rendering
74
+ # --------------------------------------------------------------------------- #
75
+ def _sh_quote(value: str) -> str:
76
+ return "'" + value.replace("'", "'\\''") + "'"
77
+
78
+
79
+ def default_python() -> str:
80
+ """The interpreter that should run CommitGuard from hooks (this one)."""
81
+ return path_for_posix_shell(sys.executable)
82
+
83
+
84
+ def _body(hook: HookType, python: str) -> list[str]:
85
+ if any(ch in python for ch in "\n\r\x00"):
86
+ raise HookInstallError("interpreter path contains control characters")
87
+ name = hook.value
88
+ lines = [
89
+ f"commitguard_hook={_sh_quote(name)}",
90
+ f"{_PYTHON_PREFIX}{_sh_quote(python)}",
91
+ f'commitguard_chained="$(dirname -- "$0")/{name}{CHAINED_SUFFIX}"',
92
+ "",
93
+ "commitguard_run() {",
94
+ ' if [ -n "$commitguard_python" ] && [ -f "$commitguard_python" ]; then',
95
+ ' "$commitguard_python" -P -m commitguard hook "$commitguard_hook" "$@"',
96
+ " elif command -v commitguard >/dev/null 2>&1; then",
97
+ ' commitguard hook "$commitguard_hook" "$@"',
98
+ " else",
99
+ ' echo "CommitGuard is not available." >&2',
100
+ ' echo "The repository\'s $commitguard_hook security hook could not execute." >&2',
101
+ ' echo "Operation blocked because the security check could not be completed." >&2',
102
+ ' echo "Run: commitguard doctor (reinstall hooks with: commitguard install)" >&2',
103
+ " return 2",
104
+ " fi",
105
+ "}",
106
+ "",
107
+ ]
108
+ if hook is HookType.PRE_PUSH:
109
+ # stdin (the ref updates) must reach both CommitGuard and the chained hook.
110
+ lines += [
111
+ "commitguard_stdin=$(cat; echo .)",
112
+ "commitguard_stdin=${commitguard_stdin%.}",
113
+ 'printf \'%s\' "$commitguard_stdin" | commitguard_run "$@" || exit $?',
114
+ 'if [ -x "$commitguard_chained" ]; then',
115
+ ' printf \'%s\' "$commitguard_stdin" | "$commitguard_chained" "$@" || exit $?',
116
+ "fi",
117
+ ]
118
+ else:
119
+ lines += [
120
+ 'commitguard_run "$@" || exit $?',
121
+ 'if [ -x "$commitguard_chained" ]; then',
122
+ ' "$commitguard_chained" "$@" || exit $?',
123
+ "fi",
124
+ ]
125
+ return lines
126
+
127
+
128
+ def _checksum(lines: list[str]) -> str:
129
+ return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
130
+
131
+
132
+ def render_block(hook: HookType, python: str) -> str:
133
+ """The managed block (markers included) for ``hook``."""
134
+ header = [
135
+ "# This block is managed by CommitGuard. Do not edit it: `commitguard doctor`",
136
+ "# detects changes and `commitguard install` restores it. `commitguard uninstall`",
137
+ "# removes it and restores any hook preserved during installation.",
138
+ f"# commitguard-hook: {hook.value}",
139
+ f"# commitguard-format: {FORMAT_VERSION}",
140
+ ]
141
+ body = _body(hook, python)
142
+ checksum_line = f"{_CHECKSUM_PREFIX}{_checksum(header + body)}"
143
+ return "\n".join([BEGIN_MARKER, *header, checksum_line, *body, END_MARKER]) + "\n"
144
+
145
+
146
+ def render_hook_file(hook: HookType, python: str) -> str:
147
+ return f"{SHEBANG}\n{render_block(hook, python)}"
148
+
149
+
150
+ # --------------------------------------------------------------------------- #
151
+ # Parsing
152
+ # --------------------------------------------------------------------------- #
153
+ class ManagedBlock(BaseModel):
154
+ model_config = ConfigDict(frozen=True, extra="forbid")
155
+
156
+ start: int # line index of BEGIN marker
157
+ end: int # line index of END marker
158
+ hook: str | None
159
+ python: str | None
160
+ intact: bool # checksum matches
161
+
162
+
163
+ def _unquote_sh(value: str) -> str | None:
164
+ if len(value) < 2 or not (value.startswith("'") and value.endswith("'")):
165
+ return None
166
+ return value[1:-1].replace("'\\''", "'")
167
+
168
+
169
+ def parse_managed_block(text: str) -> ManagedBlock | None:
170
+ """Locate CommitGuard's block. Raises :class:`HookInstallError` if markers are corrupt."""
171
+ lines = text.split("\n")
172
+ begins = [i for i, line in enumerate(lines) if line.rstrip("\r") == BEGIN_MARKER]
173
+ ends = [i for i, line in enumerate(lines) if line.rstrip("\r") == END_MARKER]
174
+ if not begins and not ends:
175
+ return None
176
+ if len(begins) != 1 or len(ends) != 1 or ends[0] < begins[0]:
177
+ raise HookInstallError("CommitGuard hook markers are corrupt")
178
+ start, end = begins[0], ends[0]
179
+ inner = [line.rstrip("\r") for line in lines[start + 1 : end]]
180
+ checksum_lines = [line for line in inner if line.startswith(_CHECKSUM_PREFIX)]
181
+ content = [line for line in inner if not line.startswith(_CHECKSUM_PREFIX)]
182
+ intact = (
183
+ len(checksum_lines) == 1 and checksum_lines[0] == f"{_CHECKSUM_PREFIX}{_checksum(content)}"
184
+ )
185
+ hook = next(
186
+ (line.split(":", 1)[1].strip() for line in inner if line.startswith("# commitguard-hook:")),
187
+ None,
188
+ )
189
+ python = next(
190
+ (
191
+ _unquote_sh(line[len(_PYTHON_PREFIX) :])
192
+ for line in inner
193
+ if line.startswith(_PYTHON_PREFIX)
194
+ ),
195
+ None,
196
+ )
197
+ return ManagedBlock(start=start, end=end, hook=hook, python=python, intact=intact)
198
+
199
+
200
+ def _remove_block(text: str, block: ManagedBlock) -> str:
201
+ lines = text.split("\n")
202
+ return "\n".join(lines[: block.start] + lines[block.end + 1 :])
203
+
204
+
205
+ def _only_shebang(text: str) -> bool:
206
+ meaningful = [line for line in text.split("\n") if line.strip()]
207
+ return not meaningful or (len(meaningful) == 1 and meaningful[0].startswith("#!"))
208
+
209
+
210
+ def _read(path: Path) -> str:
211
+ try:
212
+ return read_text_limited(path, max_bytes=MAX_HOOK_BYTES)
213
+ except (OSError, ValueError) as exc:
214
+ raise HookInstallError(f"cannot read hook {path}: {exc}") from exc
215
+
216
+
217
+ def _exists(path: Path) -> bool:
218
+ return path.exists() or path.is_symlink()
219
+
220
+
221
+ def _write_hook(path: Path, content: str, *, overwrite: bool) -> None:
222
+ try:
223
+ atomic_write_text(path, content, overwrite=overwrite, mode=0o755)
224
+ except FileExistsError as exc:
225
+ raise HookInstallError(f"{path} appeared during installation; not overwriting") from exc
226
+ except OSError as exc:
227
+ raise HookInstallError(f"cannot write hook {path}: {exc.strerror}") from exc
228
+
229
+
230
+ # --------------------------------------------------------------------------- #
231
+ # Status
232
+ # --------------------------------------------------------------------------- #
233
+ class HookState(StrEnum):
234
+ MISSING = "missing"
235
+ INSTALLED = "installed"
236
+ OUTDATED = "outdated" # managed and intact, but generated for another interpreter/format
237
+ MODIFIED = "modified" # managed block edited (checksum mismatch)
238
+ CORRUPT = "corrupt" # markers damaged
239
+ FOREIGN = "foreign" # a hook exists that CommitGuard does not manage
240
+ NOT_EXECUTABLE = "not_executable" # Git will silently skip it
241
+
242
+
243
+ class HookStatus(BaseModel):
244
+ model_config = ConfigDict(frozen=True, extra="forbid")
245
+
246
+ hook: HookType
247
+ path: Path
248
+ state: HookState
249
+ python: str | None = None
250
+ python_available: bool | None = None
251
+ chained: Path | None = None
252
+
253
+
254
+ def hook_status(
255
+ hooks_dir: Path, hook: HookType, *, expected_python: str | None = None
256
+ ) -> HookStatus:
257
+ path = hooks_dir / hook.value
258
+ chained_path = hooks_dir / f"{hook.value}{CHAINED_SUFFIX}"
259
+ chained = chained_path if _exists(chained_path) else None
260
+ if not _exists(path):
261
+ return HookStatus(hook=hook, path=path, state=HookState.MISSING, chained=chained)
262
+ try:
263
+ block = parse_managed_block(_read(path))
264
+ except HookInstallError:
265
+ return HookStatus(hook=hook, path=path, state=HookState.CORRUPT, chained=chained)
266
+ if block is None:
267
+ return HookStatus(hook=hook, path=path, state=HookState.FOREIGN, chained=chained)
268
+
269
+ python_available = bool(block.python) and Path(block.python or "").is_file()
270
+ if not block.intact or block.hook != hook.value:
271
+ state = HookState.MODIFIED
272
+ elif supports_executable_bit() and not os.access(path, os.X_OK):
273
+ state = HookState.NOT_EXECUTABLE
274
+ elif expected_python is not None and _read(path).find(render_block(hook, expected_python)) < 0:
275
+ state = HookState.OUTDATED
276
+ else:
277
+ state = HookState.INSTALLED
278
+ return HookStatus(
279
+ hook=hook,
280
+ path=path,
281
+ state=state,
282
+ python=block.python,
283
+ python_available=python_available,
284
+ chained=chained,
285
+ )
286
+
287
+
288
+ # --------------------------------------------------------------------------- #
289
+ # Install / uninstall
290
+ # --------------------------------------------------------------------------- #
291
+ class InstallAction(StrEnum):
292
+ INSTALLED = "installed"
293
+ CHAINED = "installed_and_chained" # existing hook preserved and chained
294
+ UPDATED = "updated"
295
+ UNCHANGED = "unchanged"
296
+
297
+
298
+ class InstallResult(BaseModel):
299
+ model_config = ConfigDict(frozen=True, extra="forbid")
300
+
301
+ hook: HookType
302
+ path: Path
303
+ action: InstallAction
304
+ chained: Path | None = None
305
+
306
+
307
+ class UninstallAction(StrEnum):
308
+ REMOVED = "removed"
309
+ RESTORED = "removed_and_restored" # previously existing hook put back
310
+ BLOCK_REMOVED = "block_removed" # other content in the file was kept
311
+ NOT_INSTALLED = "not_installed"
312
+ FOREIGN = "foreign_untouched"
313
+
314
+
315
+ class UninstallResult(BaseModel):
316
+ model_config = ConfigDict(frozen=True, extra="forbid")
317
+
318
+ hook: HookType
319
+ path: Path
320
+ action: UninstallAction
321
+ note: str = ""
322
+
323
+
324
+ def install_hook(hooks_dir: Path, hook: HookType, python: str) -> InstallResult:
325
+ """Install or repair one hook in ``hooks_dir`` without destroying existing hooks."""
326
+ path = hooks_dir / hook.value
327
+ chained = hooks_dir / f"{hook.value}{CHAINED_SUFFIX}"
328
+ desired_block = render_block(hook, python)
329
+
330
+ if path.is_dir():
331
+ raise HookInstallError(f"{path} is a directory")
332
+
333
+ if not _exists(path):
334
+ _write_hook(path, f"{SHEBANG}\n{desired_block}", overwrite=False)
335
+ return InstallResult(
336
+ hook=hook,
337
+ path=path,
338
+ action=InstallAction.INSTALLED,
339
+ chained=chained if _exists(chained) else None,
340
+ )
341
+
342
+ text = _read(path)
343
+ block = parse_managed_block(text)
344
+ if block is not None:
345
+ lines = text.split("\n")
346
+ current = "\n".join(lines[block.start : block.end + 1]) + "\n"
347
+ if current == desired_block and (not supports_executable_bit() or os.access(path, os.X_OK)):
348
+ return InstallResult(
349
+ hook=hook,
350
+ path=path,
351
+ action=InstallAction.UNCHANGED,
352
+ chained=chained if _exists(chained) else None,
353
+ )
354
+ new_text = "\n".join(lines[: block.start]) + ("\n" if block.start else "")
355
+ new_text += desired_block + "\n".join(lines[block.end + 1 :])
356
+ _write_hook(path, new_text, overwrite=True)
357
+ return InstallResult(
358
+ hook=hook,
359
+ path=path,
360
+ action=InstallAction.UPDATED,
361
+ chained=chained if _exists(chained) else None,
362
+ )
363
+
364
+ # A foreign hook: preserve it by renaming, then chain it.
365
+ if _exists(chained):
366
+ raise HookInstallError(
367
+ f"{path} is not managed by CommitGuard and {chained.name} already exists; "
368
+ "refusing to overwrite either. Merge them manually, then rerun install."
369
+ )
370
+ os.replace(path, chained)
371
+ try:
372
+ _write_hook(path, f"{SHEBANG}\n{desired_block}", overwrite=False)
373
+ except BaseException:
374
+ os.replace(chained, path) # roll back: the original hook is back in place
375
+ raise
376
+ return InstallResult(hook=hook, path=path, action=InstallAction.CHAINED, chained=chained)
377
+
378
+
379
+ def uninstall_hook(hooks_dir: Path, hook: HookType) -> UninstallResult:
380
+ """Remove only CommitGuard's block; restore a preserved hook where possible."""
381
+ path = hooks_dir / hook.value
382
+ chained = hooks_dir / f"{hook.value}{CHAINED_SUFFIX}"
383
+ if not _exists(path):
384
+ return UninstallResult(hook=hook, path=path, action=UninstallAction.NOT_INSTALLED)
385
+ text = _read(path)
386
+ block = parse_managed_block(text)
387
+ if block is None:
388
+ return UninstallResult(hook=hook, path=path, action=UninstallAction.FOREIGN)
389
+
390
+ remaining = _remove_block(text, block)
391
+ if not _only_shebang(remaining):
392
+ _write_hook(path, remaining, overwrite=True)
393
+ note = (
394
+ f"{chained.name} was left in place because {path.name} still has other content"
395
+ if _exists(chained)
396
+ else ""
397
+ )
398
+ return UninstallResult(
399
+ hook=hook, path=path, action=UninstallAction.BLOCK_REMOVED, note=note
400
+ )
401
+
402
+ if _exists(chained):
403
+ os.replace(chained, path) # atomic: the preserved hook replaces the wrapper
404
+ return UninstallResult(hook=hook, path=path, action=UninstallAction.RESTORED)
405
+ path.unlink()
406
+ return UninstallResult(hook=hook, path=path, action=UninstallAction.REMOVED)
407
+
408
+
409
+ def repository_hooks_dir(repository: Repository, *, allow_shared: bool = False) -> Path:
410
+ """The hooks directory to manage, refusing shared locations unless allowed."""
411
+ hooks_dir = repository.hooks_dir()
412
+ if not allow_shared:
413
+ try:
414
+ hooks_dir.resolve().relative_to(repository.common_dir.resolve())
415
+ except ValueError:
416
+ raise HookInstallError(
417
+ f"core.hooksPath points outside this repository's Git directory ({hooks_dir}). "
418
+ "Installing there would affect other repositories or tracked files. "
419
+ "Rerun with --allow-shared-hooks-path if that is intended."
420
+ ) from None
421
+ return hooks_dir
422
+
423
+
424
+ def _selected(hooks: tuple[HookType, ...] | None) -> tuple[HookType, ...]:
425
+ return hooks if hooks else tuple(HookType)
426
+
427
+
428
+ def install_hooks(
429
+ repository: Repository,
430
+ hooks: tuple[HookType, ...] | None = None,
431
+ *,
432
+ python: str | None = None,
433
+ allow_shared_hooks_path: bool = False,
434
+ ) -> list[InstallResult]:
435
+ hooks_dir = repository_hooks_dir(repository, allow_shared=allow_shared_hooks_path)
436
+ hooks_dir.mkdir(parents=True, exist_ok=True)
437
+ interpreter = python if python is not None else default_python()
438
+ return [install_hook(hooks_dir, hook, interpreter) for hook in _selected(hooks)]
439
+
440
+
441
+ def uninstall_hooks(
442
+ repository: Repository,
443
+ hooks: tuple[HookType, ...] | None = None,
444
+ *,
445
+ allow_shared_hooks_path: bool = False,
446
+ ) -> list[UninstallResult]:
447
+ hooks_dir = repository_hooks_dir(repository, allow_shared=allow_shared_hooks_path)
448
+ return [uninstall_hook(hooks_dir, hook) for hook in _selected(hooks)]
449
+
450
+
451
+ # --------------------------------------------------------------------------- #
452
+ # Global installation (Git template directory)
453
+ # --------------------------------------------------------------------------- #
454
+ # ``core.hooksPath`` is deliberately NOT used: setting it globally would stop
455
+ # every repository's own .git/hooks from running. Instead, hooks are placed in
456
+ # a Git template directory, which ``git init`` and ``git clone`` copy into
457
+ # *new* repositories only. Existing repositories need ``commitguard install``.
458
+
459
+
460
+ def global_template_dir() -> Path:
461
+ return global_config_path().parent / "git-template"
462
+
463
+
464
+ def _global_template_setting() -> str | None:
465
+ result = run_git(["config", "--global", "--get", "init.templateDir"], check=False)
466
+ if result.returncode == 1:
467
+ return None
468
+ if not result.ok:
469
+ raise GitCommandError(result.args, result.returncode, "git config failed")
470
+ return result.stdout.decode("utf-8", errors="replace").strip()
471
+
472
+
473
+ def _same_path(configured: str, path: Path) -> bool:
474
+ try:
475
+ return Path(configured).expanduser().resolve() == path.resolve()
476
+ except OSError:
477
+ return False
478
+
479
+
480
+ def install_global(
481
+ hooks: tuple[HookType, ...] | None = None, *, python: str | None = None
482
+ ) -> tuple[Path, list[InstallResult], bool]:
483
+ """Install hooks into the CommitGuard Git template directory.
484
+
485
+ Returns ``(template_dir, results, configured)`` where ``configured`` is True
486
+ if ``init.templateDir`` was set by this call. Refuses to replace a
487
+ different, user-configured template directory.
488
+ """
489
+ template = global_template_dir()
490
+ current = _global_template_setting()
491
+ if current is not None and not _same_path(current, template):
492
+ raise HookInstallError(
493
+ f"global init.templateDir is already set to {current!r}; not changing it. "
494
+ "Add CommitGuard hooks to that template manually or use per-repository install."
495
+ )
496
+ hooks_dir = template / "hooks"
497
+ hooks_dir.mkdir(parents=True, exist_ok=True)
498
+ interpreter = python if python is not None else default_python()
499
+ results = []
500
+ for hook in _selected(hooks):
501
+ path = hooks_dir / hook.value
502
+ if _exists(path) and parse_managed_block(_read(path)) is None:
503
+ raise HookInstallError(f"{path} exists and is not managed by CommitGuard")
504
+ results.append(install_hook(hooks_dir, hook, interpreter))
505
+ configured = False
506
+ if current is None:
507
+ run_git(["config", "--global", "init.templateDir", path_for_posix_shell(str(template))])
508
+ configured = True
509
+ return template, results, configured
510
+
511
+
512
+ def uninstall_global(
513
+ hooks: tuple[HookType, ...] | None = None,
514
+ ) -> tuple[Path, list[UninstallResult], bool]:
515
+ """Remove CommitGuard template hooks; unset init.templateDir only if it is ours."""
516
+ template = global_template_dir()
517
+ hooks_dir = template / "hooks"
518
+ results = (
519
+ [uninstall_hook(hooks_dir, hook) for hook in _selected(hooks)] if hooks_dir.is_dir() else []
520
+ )
521
+ unset = False
522
+ current = _global_template_setting()
523
+ remaining = hooks_dir.is_dir() and any(hooks_dir.iterdir())
524
+ if current is not None and _same_path(current, template) and not remaining:
525
+ run_git(["config", "--global", "--unset", "init.templateDir"])
526
+ unset = True
527
+ return template, results, unset
@@ -0,0 +1,93 @@
1
+ """Parsing of the ``pre-push`` hook's standard input.
2
+
3
+ Git writes one line per ref update::
4
+
5
+ <local ref> SP <local object name> SP <remote ref> SP <remote object name> LF
6
+
7
+ * a deletion has local ref ``(delete)`` and an all-zero local object name;
8
+ * a new remote ref has an all-zero remote object name;
9
+ * annotated tags arrive as *tag object* names, not commit names.
10
+
11
+ Ref names are untrusted: they may legally contain quotes, ``;``, ``$()``,
12
+ backticks, ``|`` and non-ASCII characters. They are only ever passed to Git as
13
+ single argument-vector elements or displayed after sanitisation. Git ref names
14
+ cannot contain spaces, which makes the four-field split unambiguous.
15
+ """
16
+
17
+ from pydantic import BaseModel, ConfigDict
18
+
19
+ from commitguard.exceptions.base import UnsafeInputError
20
+ from commitguard.security.validation import is_git_sha
21
+
22
+ MAX_UPDATES = 10_000
23
+ MAX_REF_LENGTH = 4096
24
+ DELETE_LOCAL_REF = "(delete)"
25
+
26
+
27
+ def is_zero_oid(oid: str) -> bool:
28
+ return bool(oid) and set(oid) == {"0"}
29
+
30
+
31
+ class PushUpdate(BaseModel):
32
+ """One ref update announced to the pre-push hook."""
33
+
34
+ model_config = ConfigDict(frozen=True, extra="forbid")
35
+
36
+ local_ref: str
37
+ local_oid: str
38
+ remote_ref: str
39
+ remote_oid: str
40
+
41
+ @property
42
+ def is_delete(self) -> bool:
43
+ return is_zero_oid(self.local_oid)
44
+
45
+ @property
46
+ def is_new_ref(self) -> bool:
47
+ return is_zero_oid(self.remote_oid)
48
+
49
+ @property
50
+ def is_tag(self) -> bool:
51
+ return self.remote_ref.startswith("refs/tags/")
52
+
53
+
54
+ def _valid_oid(oid: str) -> bool:
55
+ return is_git_sha(oid) or (len(oid) in (40, 64) and is_zero_oid(oid))
56
+
57
+
58
+ def _valid_ref(ref: str) -> bool:
59
+ return 0 < len(ref) <= MAX_REF_LENGTH and not any(
60
+ ord(ch) < 0x20 or ord(ch) == 0x7F for ch in ref
61
+ )
62
+
63
+
64
+ def parse_pre_push_input(text: str) -> tuple[PushUpdate, ...]:
65
+ """Parse and validate pre-push stdin. Malformed input raises (fail closed)."""
66
+ updates: list[PushUpdate] = []
67
+ for number, line in enumerate(text.split("\n"), start=1):
68
+ line = line.removesuffix("\r")
69
+ if not line:
70
+ continue
71
+ fields = line.split(" ")
72
+ if len(fields) != 4:
73
+ raise UnsafeInputError(f"pre-push input line {number}: expected 4 fields")
74
+ local_ref, local_oid, remote_ref, remote_oid = fields
75
+ if not (_valid_oid(local_oid) and _valid_oid(remote_oid)):
76
+ raise UnsafeInputError(f"pre-push input line {number}: invalid object name")
77
+ if len(local_oid) != len(remote_oid):
78
+ raise UnsafeInputError(f"pre-push input line {number}: mixed object formats")
79
+ if not (_valid_ref(local_ref) and _valid_ref(remote_ref)):
80
+ raise UnsafeInputError(f"pre-push input line {number}: invalid ref name")
81
+ if is_zero_oid(local_oid) != (local_ref == DELETE_LOCAL_REF):
82
+ raise UnsafeInputError(f"pre-push input line {number}: inconsistent deletion")
83
+ if len(updates) >= MAX_UPDATES:
84
+ raise UnsafeInputError("too many ref updates in one push")
85
+ updates.append(
86
+ PushUpdate(
87
+ local_ref=local_ref,
88
+ local_oid=local_oid,
89
+ remote_ref=remote_ref,
90
+ remote_oid=remote_oid,
91
+ )
92
+ )
93
+ return tuple(updates)
@@ -0,0 +1,71 @@
1
+ """Commit ranges: which commits an operation introduces.
2
+
3
+ One abstraction serves every enforcement point:
4
+
5
+ * ``pre-push`` - head = pushed commit, exclude = remote's old commit + tracking refs
6
+ * pull requests - head = PR head, exclude = PR base
7
+ * push events (CI) - head = ``after``, exclude = ``before`` (or the default branch)
8
+ * ``scan a..b`` - head = b, exclude = a
9
+
10
+ The selected commits are those reachable from ``head`` but from none of the
11
+ ``exclude`` commits (``git rev-list head ^exclude...``), oldest first. Merge
12
+ commits are included like any other commit; history reachable from an excluded
13
+ commit is never walked into the result.
14
+ """
15
+
16
+ from collections.abc import Iterable
17
+
18
+ from pydantic import BaseModel, ConfigDict
19
+
20
+ from commitguard.exceptions.git import GitError
21
+ from commitguard.git.repository import Repository
22
+
23
+
24
+ class MissingCommitError(GitError):
25
+ """A commit needed to compute a range is not present in the local clone."""
26
+
27
+
28
+ class CommitRange(BaseModel):
29
+ """A resolved range. ``head=None`` means nothing to analyse (e.g. deletion)."""
30
+
31
+ model_config = ConfigDict(frozen=True, extra="forbid")
32
+
33
+ base: str | None = None # primary excluded commit, for display (PR base, push before)
34
+ head: str | None = None
35
+ exclude: tuple[str, ...] = ()
36
+ commits: tuple[str, ...] = ()
37
+
38
+ @property
39
+ def empty(self) -> bool:
40
+ return not self.commits
41
+
42
+
43
+ def resolve_commit_range(
44
+ repository: Repository,
45
+ head: str | None,
46
+ exclude: Iterable[str] = (),
47
+ *,
48
+ base: str | None = None,
49
+ max_count: int,
50
+ ) -> CommitRange:
51
+ """Resolve the commits reachable from ``head`` but not from any ``exclude``.
52
+
53
+ ``head`` and ``exclude`` must be full commit IDs present in the repository;
54
+ use :func:`require_commit` first for IDs from untrusted sources.
55
+ """
56
+ if head is None:
57
+ return CommitRange(base=base)
58
+ excluded = tuple(sorted(set(exclude)))
59
+ commits = repository.rev_list([head], excluded, max_count=max_count)
60
+ return CommitRange(base=base, head=head, exclude=excluded, commits=tuple(commits))
61
+
62
+
63
+ def require_commit(repository: Repository, oid: str, *, role: str) -> str:
64
+ """Peel ``oid`` (commit or annotated tag) to a commit, or raise a clear error."""
65
+ commit = repository.peel_to_commit(oid)
66
+ if commit is None:
67
+ raise MissingCommitError(
68
+ f"{role} commit {oid} is not available in this clone. "
69
+ "Fetch the full history (for actions/checkout: fetch-depth: 0)."
70
+ )
71
+ return commit