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,247 @@
1
+ """Git operations with and without CommitGuard hooks.
2
+
3
+ Two otherwise identical repositories are created: one plain, one with the
4
+ CommitGuard hooks (``pre-commit``, ``commit-msg``, ``pre-push``) installed by
5
+ the production installer. The same Git commands run against both, repeatedly,
6
+ and the difference in wall-clock time is the hook overhead.
7
+
8
+ The benchmark also records what happened (exit codes), because the timings are
9
+ only meaningful if the hooks actually enforced:
10
+
11
+ * a clean commit is allowed with and without hooks;
12
+ * an AI co-authored commit is blocked by the hooks;
13
+ * ``git commit --no-verify`` bypasses the local hooks (measured, not assumed);
14
+ * a push of a violating commit is blocked by ``pre-push``.
15
+ """
16
+
17
+ import sys
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+
21
+ from pydantic import BaseModel, ConfigDict
22
+
23
+ from commitguard.git.hooks import install_hooks
24
+ from commitguard.git.repository import Repository
25
+ from commitguard.research.gitenv import Workspace, workspace
26
+ from commitguard.research.metrics import LatencySummary
27
+
28
+ BENCHMARK_VERSION = "1.0.0"
29
+ DEFAULT_REPETITIONS = 20
30
+
31
+ CLEAN = "feat: add session rotation\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
32
+ AI = "feat: add payment service\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"
33
+
34
+
35
+ class Scenario(BaseModel):
36
+ model_config = ConfigDict(frozen=True, extra="forbid")
37
+
38
+ name: str
39
+ description: str
40
+ hooks_installed: bool
41
+ exit_codes: dict[str, int]
42
+ latency: LatencySummary
43
+
44
+
45
+ class Overhead(BaseModel):
46
+ model_config = ConfigDict(frozen=True, extra="forbid")
47
+
48
+ operation: str
49
+ without_hooks_p50_ms: float
50
+ with_hooks_p50_ms: float
51
+ overhead_p50_ms: float
52
+ overhead_ratio: float | None
53
+
54
+
55
+ class Observation(BaseModel):
56
+ model_config = ConfigDict(frozen=True, extra="forbid")
57
+
58
+ check: str
59
+ expected: str
60
+ observed: str
61
+ matches: bool
62
+
63
+
64
+ class HooksResult(BaseModel):
65
+ model_config = ConfigDict(frozen=True, extra="forbid")
66
+
67
+ benchmark: str = "hooks"
68
+ benchmark_version: str = BENCHMARK_VERSION
69
+ repetitions: int
70
+ python: str
71
+ scenarios: tuple[Scenario, ...]
72
+ overhead: tuple[Overhead, ...]
73
+ observations: tuple[Observation, ...]
74
+ notes: tuple[str, ...]
75
+
76
+
77
+ def _measure(
78
+ name: str,
79
+ description: str,
80
+ hooked: bool,
81
+ repetitions: int,
82
+ run: Callable[[int], tuple[float, int]],
83
+ ) -> Scenario:
84
+ durations = []
85
+ codes: dict[str, int] = {}
86
+ for index in range(repetitions):
87
+ seconds, code = run(index)
88
+ durations.append(seconds)
89
+ codes[str(code)] = codes.get(str(code), 0) + 1
90
+ return Scenario(
91
+ name=name,
92
+ description=description,
93
+ hooks_installed=hooked,
94
+ exit_codes=codes,
95
+ latency=LatencySummary.from_seconds(durations),
96
+ )
97
+
98
+
99
+ def _repository(space: Workspace, name: str, hooked: bool) -> tuple[Repository, str]:
100
+ remote = space.init(f"{name}-remote.git", bare=True)
101
+ path = space.init(name)
102
+ space.git(path, "remote", "add", "origin", str(remote))
103
+ space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", "chore: initial")
104
+ space.git(path, "push", "--quiet", "--no-verify", "origin", "main")
105
+ repository = Repository.discover(path)
106
+ if hooked:
107
+ install_hooks(repository, python=sys.executable)
108
+ return repository, name
109
+
110
+
111
+ def run_hooks(repetitions: int = DEFAULT_REPETITIONS) -> HooksResult:
112
+ if repetitions < 1:
113
+ raise ValueError("repetitions must be at least 1")
114
+ with workspace() as space:
115
+ plain, _ = _repository(space, "plain", hooked=False)
116
+ hooked, _ = _repository(space, "hooked", hooked=True)
117
+ p, h = plain.root, hooked.root
118
+
119
+ def commit(path: Path, message: str, *extra: str) -> Callable[[int], tuple[float, int]]:
120
+ def run(_: int) -> tuple[float, int]:
121
+ seconds, result = space.timed_git(
122
+ path, "commit", "--quiet", "--allow-empty", *extra, "-m", message
123
+ )
124
+ return seconds, result.returncode
125
+
126
+ return run
127
+
128
+ def push(
129
+ label: str, path: Path, message: str, *extra: str
130
+ ) -> Callable[[int], tuple[float, int]]:
131
+ def run(index: int) -> tuple[float, int]:
132
+ branch = f"{label}-{index}" # unique: a reused name could be rejected by the remote
133
+ # Start from the remote state so earlier scenarios cannot leak commits in.
134
+ space.git(path, "checkout", "--quiet", "-B", branch, "origin/main")
135
+ space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", message)
136
+ seconds, result = space.timed_git(path, "push", "--quiet", *extra, "origin", branch)
137
+ return seconds, result.returncode
138
+
139
+ return run
140
+
141
+ scenarios = (
142
+ _measure(
143
+ "commit-clean-plain",
144
+ "git commit, clean message, no hooks",
145
+ False,
146
+ repetitions,
147
+ commit(p, CLEAN),
148
+ ),
149
+ _measure(
150
+ "commit-clean-hooked",
151
+ "git commit, clean message, CommitGuard hooks",
152
+ True,
153
+ repetitions,
154
+ commit(h, CLEAN),
155
+ ),
156
+ _measure(
157
+ "commit-ai-hooked",
158
+ "git commit, AI co-author, CommitGuard hooks",
159
+ True,
160
+ repetitions,
161
+ commit(h, AI),
162
+ ),
163
+ _measure(
164
+ "commit-ai-no-verify",
165
+ "git commit --no-verify, AI co-author, hooks installed",
166
+ True,
167
+ repetitions,
168
+ commit(h, AI, "--no-verify"),
169
+ ),
170
+ _measure(
171
+ "push-clean-plain",
172
+ "git push of one new clean commit, no hooks",
173
+ False,
174
+ repetitions,
175
+ push("push-clean-plain", p, CLEAN),
176
+ ),
177
+ _measure(
178
+ "push-clean-hooked",
179
+ "git push of one new clean commit, CommitGuard pre-push",
180
+ True,
181
+ repetitions,
182
+ push("push-clean-hooked", h, CLEAN),
183
+ ),
184
+ _measure(
185
+ "push-ai-hooked",
186
+ "git push of one new AI co-authored commit, pre-push",
187
+ True,
188
+ repetitions,
189
+ push("push-ai-hooked", h, AI),
190
+ ),
191
+ _measure(
192
+ "push-ai-no-verify",
193
+ "git push --no-verify of an AI co-authored commit",
194
+ True,
195
+ repetitions,
196
+ push("push-ai-no-verify", h, AI, "--no-verify"),
197
+ ),
198
+ )
199
+ by_name = {s.name: s for s in scenarios}
200
+
201
+ def overhead(operation: str, plain_name: str, hooked_name: str) -> Overhead:
202
+ base, with_hooks = by_name[plain_name].latency.p50_ms, by_name[hooked_name].latency.p50_ms
203
+ return Overhead(
204
+ operation=operation,
205
+ without_hooks_p50_ms=base,
206
+ with_hooks_p50_ms=with_hooks,
207
+ overhead_p50_ms=round(with_hooks - base, 3),
208
+ overhead_ratio=round(with_hooks / base, 2) if base > 0 else None,
209
+ )
210
+
211
+ def observe(check: str, scenario: str, expected_code: str) -> Observation:
212
+ codes = by_name[scenario].exit_codes
213
+ observed = ", ".join(f"exit {code} x{count}" for code, count in sorted(codes.items()))
214
+ matches = set(codes) == {expected_code} if expected_code == "0" else "0" not in codes
215
+ return Observation(
216
+ check=check,
217
+ expected=f"exit {'0' if expected_code == '0' else 'non-zero'} every time",
218
+ observed=observed,
219
+ matches=matches,
220
+ )
221
+
222
+ observations = (
223
+ observe("clean commit allowed without hooks", "commit-clean-plain", "0"),
224
+ observe("clean commit allowed with hooks", "commit-clean-hooked", "0"),
225
+ observe("AI co-authored commit blocked by commit hooks", "commit-ai-hooked", "!0"),
226
+ observe("--no-verify bypasses local commit hooks", "commit-ai-no-verify", "0"),
227
+ observe("clean push allowed by pre-push", "push-clean-hooked", "0"),
228
+ observe("AI co-authored push blocked by pre-push", "push-ai-hooked", "!0"),
229
+ observe("--no-verify bypasses the local pre-push hook", "push-ai-no-verify", "0"),
230
+ )
231
+ return HooksResult(
232
+ repetitions=repetitions,
233
+ python=sys.executable,
234
+ scenarios=scenarios,
235
+ overhead=(
236
+ overhead("git commit (clean)", "commit-clean-plain", "commit-clean-hooked"),
237
+ overhead("git push (clean, one commit)", "push-clean-plain", "push-clean-hooked"),
238
+ ),
239
+ observations=observations,
240
+ notes=(
241
+ "Each hook starts a Python interpreter; interpreter start-up is part of the "
242
+ "measured overhead.",
243
+ "Pushes go to a local bare repository, so network latency is excluded.",
244
+ "--no-verify results demonstrate that local hooks are advisory; server-side "
245
+ "enforcement is measured separately.",
246
+ ),
247
+ )
@@ -0,0 +1,85 @@
1
+ """Measurement arithmetic: confusion matrices and latency statistics.
2
+
3
+ CommitGuard's detection is deterministic and rule-based. Precision, recall and
4
+ F1 are reported because they are the standard way to state how often a
5
+ classifier is right on a labelled set - not to suggest a statistical model.
6
+ Undefined ratios (a zero denominator) are ``None``, never a made-up 0 or 1.
7
+ """
8
+
9
+ import math
10
+ from collections.abc import Sequence
11
+
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+
15
+ class Confusion(BaseModel):
16
+ model_config = ConfigDict(frozen=True, extra="forbid")
17
+
18
+ true_positive: int
19
+ false_positive: int
20
+ true_negative: int
21
+ false_negative: int
22
+
23
+ @property
24
+ def total(self) -> int:
25
+ return self.true_positive + self.false_positive + self.true_negative + self.false_negative
26
+
27
+ def rates(self) -> dict[str, float | None]:
28
+ tp, fp, tn, fn = (
29
+ self.true_positive,
30
+ self.false_positive,
31
+ self.true_negative,
32
+ self.false_negative,
33
+ )
34
+ precision = _ratio(tp, tp + fp)
35
+ recall = _ratio(tp, tp + fn)
36
+ f1 = (
37
+ None
38
+ if precision is None or recall is None or precision + recall == 0
39
+ else 2 * precision * recall / (precision + recall)
40
+ )
41
+ return {
42
+ "precision": precision,
43
+ "recall": recall,
44
+ "f1": f1,
45
+ "accuracy": _ratio(tp + tn, self.total),
46
+ "false_positive_rate": _ratio(fp, fp + tn),
47
+ "false_negative_rate": _ratio(fn, fn + tp),
48
+ }
49
+
50
+
51
+ def _ratio(numerator: int, denominator: int) -> float | None:
52
+ return None if denominator == 0 else numerator / denominator
53
+
54
+
55
+ def percentile(sorted_values: Sequence[float], fraction: float) -> float:
56
+ """Nearest-rank percentile of an ascending sequence (no interpolation)."""
57
+ if not sorted_values:
58
+ raise ValueError("no values")
59
+ rank = max(1, math.ceil(fraction * len(sorted_values)))
60
+ return sorted_values[rank - 1]
61
+
62
+
63
+ class LatencySummary(BaseModel):
64
+ model_config = ConfigDict(frozen=True, extra="forbid")
65
+
66
+ samples: int
67
+ min_ms: float
68
+ mean_ms: float
69
+ p50_ms: float
70
+ p95_ms: float
71
+ p99_ms: float
72
+ max_ms: float
73
+
74
+ @classmethod
75
+ def from_seconds(cls, durations: Sequence[float]) -> "LatencySummary":
76
+ values = sorted(d * 1000 for d in durations)
77
+ return cls(
78
+ samples=len(values),
79
+ min_ms=round(values[0], 4),
80
+ mean_ms=round(sum(values) / len(values), 4),
81
+ p50_ms=round(percentile(values, 0.50), 4),
82
+ p95_ms=round(percentile(values, 0.95), 4),
83
+ p99_ms=round(percentile(values, 0.99), 4),
84
+ max_ms=round(values[-1], 4),
85
+ )
@@ -0,0 +1,194 @@
1
+ """Detection performance: latency, message-size scaling, CPU time and memory.
2
+
3
+ What is measured, and how:
4
+
5
+ * **wall-clock** - ``time.perf_counter`` around each ``Analyzer.analyze`` call;
6
+ * **CPU time** - ``time.process_time`` over a whole batch (user + system time of
7
+ this process);
8
+ * **Python allocations** - ``tracemalloc`` peak during a *separate* pass over the
9
+ same commits: memory allocated by the analysis itself, excluding the
10
+ interpreter's baseline. Timing passes run without ``tracemalloc``, which slows
11
+ Python code by roughly 2-3x;
12
+ * **peak RSS** - the process's maximum resident set size (``resource``; not
13
+ available on Windows, reported as ``None`` there). RSS is a process-lifetime
14
+ high-water mark, so it is reported once, after all scenarios.
15
+
16
+ Batches reuse one analyzer, as the hooks and the GitHub App do. A warm-up pass
17
+ runs first so imports and rule compilation are not attributed to the first
18
+ sample; rule loading is measured separately (``startup``).
19
+ """
20
+
21
+ import gc
22
+ import sys
23
+ import time
24
+ import tracemalloc
25
+ from collections.abc import Sequence
26
+
27
+ from pydantic import BaseModel, ConfigDict
28
+
29
+ from commitguard.git.commit import Commit
30
+ from commitguard.policies.defaults import default_policy_set
31
+ from commitguard.provenance.author import Identity
32
+ from commitguard.research.metrics import LatencySummary
33
+ from commitguard.services.analysis import Analyzer
34
+
35
+ BENCHMARK_VERSION = "1.0.0"
36
+ BATCH_SIZES = (1, 10, 100, 1_000, 10_000)
37
+ MESSAGE_SIZES = (1_024, 10_240, 102_400, 1_048_576, 10_485_760)
38
+
39
+ HUMAN = Identity(name="Ada Lovelace", email="ada@example.com")
40
+ TRAILER = "Co-authored-by: Claude <noreply@anthropic.com>"
41
+
42
+
43
+ class BatchResult(BaseModel):
44
+ model_config = ConfigDict(frozen=True, extra="forbid")
45
+
46
+ commits: int
47
+ wall_ms: float
48
+ cpu_ms: float
49
+ commits_per_second: float
50
+ latency: LatencySummary
51
+ python_peak_allocated_bytes: int
52
+
53
+
54
+ class SizeResult(BaseModel):
55
+ model_config = ConfigDict(frozen=True, extra="forbid")
56
+
57
+ message_bytes: int
58
+ repetitions: int
59
+ decision: str
60
+ latency: LatencySummary
61
+ cpu_ms_per_commit: float
62
+ python_peak_allocated_bytes: int
63
+
64
+
65
+ class PerformanceResult(BaseModel):
66
+ model_config = ConfigDict(frozen=True, extra="forbid")
67
+
68
+ benchmark: str = "performance"
69
+ benchmark_version: str = BENCHMARK_VERSION
70
+ startup_ms: float
71
+ batches: tuple[BatchResult, ...]
72
+ message_sizes: tuple[SizeResult, ...]
73
+ peak_rss_bytes: int | None
74
+ notes: tuple[str, ...]
75
+
76
+
77
+ def _mixed_commits(count: int) -> list[Commit]:
78
+ """Realistic mix: 60% clean with human trailers, 35% AI co-author, 5% bot author."""
79
+ commits = []
80
+ for index in range(count):
81
+ kind = index % 20
82
+ if kind < 12:
83
+ message = f"fix: handle case {index}\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
84
+ author = HUMAN
85
+ elif kind < 19:
86
+ message = f"feat: add feature {index}\n\n{TRAILER}\n"
87
+ author = HUMAN
88
+ else:
89
+ message = "chore(deps): bump a dependency\n"
90
+ author = Identity(
91
+ name="dependabot[bot]", email="49699333+dependabot[bot]@users.noreply.github.com"
92
+ )
93
+ commits.append(Commit(author=author, committer=author, message=message))
94
+ return commits
95
+
96
+
97
+ def _sized_commit(size: int) -> Commit:
98
+ """A commit of about ``size`` bytes: wrapped prose, then an AI co-author trailer."""
99
+ line = "The storage layer keeps immutable versions of every published policy.\n"
100
+ body = (line * (size // len(line) + 1))[: max(0, size - len(TRAILER) - 40)]
101
+ return Commit(author=HUMAN, committer=HUMAN, message=f"docs: notes\n\n{body}\n\n{TRAILER}\n")
102
+
103
+
104
+ def peak_rss_bytes() -> int | None:
105
+ try:
106
+ import resource
107
+ except ImportError: # Windows
108
+ return None
109
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
110
+ return int(peak if sys.platform == "darwin" else peak * 1024) # macOS: bytes, Linux: KiB
111
+
112
+
113
+ def _allocation_peak(analyzer: Analyzer, commits: Sequence[Commit]) -> int:
114
+ gc.collect()
115
+ tracemalloc.start()
116
+ try:
117
+ for commit in commits:
118
+ analyzer.analyze(commit)
119
+ return tracemalloc.get_traced_memory()[1]
120
+ finally:
121
+ tracemalloc.stop()
122
+
123
+
124
+ def _batch(analyzer: Analyzer, commits: Sequence[Commit]) -> BatchResult:
125
+ gc.collect()
126
+ durations = []
127
+ cpu_start = time.process_time()
128
+ wall_start = time.perf_counter()
129
+ for commit in commits:
130
+ begin = time.perf_counter()
131
+ analyzer.analyze(commit)
132
+ durations.append(time.perf_counter() - begin)
133
+ wall = time.perf_counter() - wall_start
134
+ cpu = time.process_time() - cpu_start
135
+ peak = _allocation_peak(analyzer, commits)
136
+ return BatchResult(
137
+ commits=len(commits),
138
+ wall_ms=round(wall * 1000, 3),
139
+ cpu_ms=round(cpu * 1000, 3),
140
+ commits_per_second=round(len(commits) / wall, 1) if wall > 0 else 0.0,
141
+ latency=LatencySummary.from_seconds(durations),
142
+ python_peak_allocated_bytes=peak,
143
+ )
144
+
145
+
146
+ def _size(analyzer: Analyzer, size: int) -> SizeResult:
147
+ commit = _sized_commit(size)
148
+ repetitions = max(5, min(200, 50_000_000 // max(size, 1)))
149
+ gc.collect()
150
+ durations = []
151
+ decision = ""
152
+ cpu_start = time.process_time()
153
+ for _ in range(repetitions):
154
+ begin = time.perf_counter()
155
+ report = analyzer.analyze(commit)
156
+ durations.append(time.perf_counter() - begin)
157
+ decision = report.action.value
158
+ cpu = time.process_time() - cpu_start
159
+ peak = _allocation_peak(analyzer, [commit])
160
+ return SizeResult(
161
+ message_bytes=len(commit.message.encode("utf-8")),
162
+ repetitions=repetitions,
163
+ decision=decision,
164
+ latency=LatencySummary.from_seconds(durations),
165
+ cpu_ms_per_commit=round(cpu * 1000 / repetitions, 4),
166
+ python_peak_allocated_bytes=peak,
167
+ )
168
+
169
+
170
+ def run_performance(
171
+ *,
172
+ batch_sizes: Sequence[int] = BATCH_SIZES,
173
+ message_sizes: Sequence[int] = MESSAGE_SIZES,
174
+ ) -> PerformanceResult:
175
+ started = time.perf_counter()
176
+ analyzer = Analyzer.create(default_policy_set())
177
+ startup = time.perf_counter() - started
178
+ for commit in _mixed_commits(200): # warm-up
179
+ analyzer.analyze(commit)
180
+ batches = tuple(_batch(analyzer, _mixed_commits(size)) for size in batch_sizes)
181
+ sizes = tuple(_size(analyzer, size) for size in message_sizes)
182
+ notes = (
183
+ "Commits are built in memory: this isolates detection and policy evaluation from Git I/O "
184
+ "(see the hooks and repository benchmarks for end-to-end Git timings).",
185
+ "startup_ms includes loading and compiling the built-in rules (cached per process).",
186
+ "Allocation peaks come from a separate tracemalloc pass; timings are measured without it.",
187
+ )
188
+ return PerformanceResult(
189
+ startup_ms=round(startup * 1000, 3),
190
+ batches=batches,
191
+ message_sizes=sizes,
192
+ peak_rss_bytes=peak_rss_bytes(),
193
+ notes=notes,
194
+ )