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,183 @@
1
+ """Detection accuracy on the labelled dataset.
2
+
3
+ Each case runs through the production :class:`~commitguard.services.analysis.Analyzer`
4
+ with the built-in rules and the built-in policy - the configuration a new
5
+ installation enforces. Three views are measured:
6
+
7
+ * **decision (binary)** - positive = the case must not be allowed silently
8
+ (expected WARN or BLOCK); predicted positive = CommitGuard did not ALLOW.
9
+ This is the security-relevant question: was a violation let through?
10
+ * **decision (exact)** - the reached decision equals the expected one
11
+ (BLOCK vs WARN matters: a warning does not stop a merge);
12
+ * **rules** - per rule, whether it was reported when required, and whether a
13
+ rule was reported that is neither required nor permitted.
14
+
15
+ Every mismatch is listed with the case, what was expected and what happened.
16
+ """
17
+
18
+ import time
19
+ from collections import Counter
20
+ from collections.abc import Sequence
21
+
22
+ from pydantic import BaseModel, ConfigDict
23
+
24
+ from commitguard.core.decision import Action
25
+ from commitguard.git.commit import Commit
26
+ from commitguard.policies.defaults import DEFAULT_POLICIES, default_policy_set
27
+ from commitguard.provenance.author import Identity
28
+ from commitguard.research.datasets import DatasetCase
29
+ from commitguard.research.metrics import Confusion, LatencySummary
30
+ from commitguard.services.analysis import Analyzer
31
+ from commitguard.services.reports import CommitReport
32
+
33
+ BENCHMARK_VERSION = "1.0.0"
34
+
35
+
36
+ class CaseOutcome(BaseModel):
37
+ model_config = ConfigDict(frozen=True, extra="forbid")
38
+
39
+ id: str
40
+ case_class: str
41
+ category: str
42
+ description: str
43
+ expected_decision: str
44
+ decision: str
45
+ required_rules: tuple[str, ...]
46
+ reported_rules: tuple[str, ...]
47
+ missing_rules: tuple[str, ...]
48
+ unexpected_rules: tuple[str, ...]
49
+ failed_closed: bool
50
+ outcome: str # true_positive | false_positive | true_negative | false_negative
51
+
52
+
53
+ class DetectionResult(BaseModel):
54
+ model_config = ConfigDict(frozen=True, extra="forbid")
55
+
56
+ benchmark: str = "detection"
57
+ benchmark_version: str = BENCHMARK_VERSION
58
+ cases: int
59
+ positives: int
60
+ negatives: int
61
+ decision: Confusion
62
+ decision_rates: dict[str, float | None]
63
+ exact_decision_matches: int
64
+ exact_decision_accuracy: float | None
65
+ rules: dict[str, Confusion]
66
+ rule_rates: dict[str, dict[str, float | None]]
67
+ by_class: dict[str, dict[str, int]]
68
+ by_decision: dict[str, dict[str, int]]
69
+ latency: LatencySummary
70
+ duration_ms: float
71
+ mismatches: tuple[CaseOutcome, ...]
72
+
73
+
74
+ def commit_for(case: DatasetCase) -> Commit:
75
+ return Commit(
76
+ author=Identity(name=case.author.name, email=case.author.email),
77
+ committer=Identity(name=case.committer.name, email=case.committer.email),
78
+ message=case.message,
79
+ )
80
+
81
+
82
+ def _outcome(case: DatasetCase, report: CommitReport) -> CaseOutcome:
83
+ reported = tuple(sorted({f.finding.rule_id for f in report.findings}))
84
+ required = set(case.required_rules)
85
+ allowed = required | set(case.permitted_rules)
86
+ predicted_positive = report.action is not Action.ALLOW
87
+ if case.positive:
88
+ kind = "true_positive" if predicted_positive else "false_negative"
89
+ else:
90
+ kind = "false_positive" if predicted_positive else "true_negative"
91
+ return CaseOutcome(
92
+ id=case.id,
93
+ case_class=case.case_class,
94
+ category=case.category,
95
+ description=case.description,
96
+ expected_decision=case.expected_decision,
97
+ decision=report.action.value,
98
+ required_rules=case.required_rules,
99
+ reported_rules=reported,
100
+ missing_rules=tuple(sorted(required - set(reported))),
101
+ unexpected_rules=tuple(sorted(set(reported) - allowed)),
102
+ failed_closed=bool(report.failures),
103
+ outcome=kind,
104
+ )
105
+
106
+
107
+ def run_detection(
108
+ cases: Sequence[DatasetCase], analyzer: Analyzer | None = None
109
+ ) -> DetectionResult:
110
+ if not cases:
111
+ raise ValueError("the dataset is empty")
112
+ analyzer = analyzer or Analyzer.create(default_policy_set())
113
+ outcomes: list[CaseOutcome] = []
114
+ durations: list[float] = []
115
+ started = time.perf_counter()
116
+ for case in cases:
117
+ commit = commit_for(case)
118
+ begin = time.perf_counter()
119
+ report = analyzer.analyze(commit)
120
+ durations.append(time.perf_counter() - begin)
121
+ outcomes.append(_outcome(case, report))
122
+ total = time.perf_counter() - started
123
+
124
+ counts = Counter(o.outcome for o in outcomes)
125
+ decision = Confusion(
126
+ true_positive=counts["true_positive"],
127
+ false_positive=counts["false_positive"],
128
+ true_negative=counts["true_negative"],
129
+ false_negative=counts["false_negative"],
130
+ )
131
+ rules: dict[str, Confusion] = {}
132
+ for rule in sorted(DEFAULT_POLICIES):
133
+ tp = fp = tn = fn = 0
134
+ for case, outcome in zip(cases, outcomes, strict=True):
135
+ expected = rule in case.required_rules
136
+ permitted = rule in case.permitted_rules
137
+ reported = rule in outcome.reported_rules
138
+ if expected:
139
+ tp, fn = (tp + 1, fn) if reported else (tp, fn + 1)
140
+ elif permitted:
141
+ continue # either answer is acceptable for this case
142
+ elif reported:
143
+ fp += 1
144
+ else:
145
+ tn += 1
146
+ rules[rule] = Confusion(
147
+ true_positive=tp, false_positive=fp, true_negative=tn, false_negative=fn
148
+ )
149
+
150
+ by_class: dict[str, dict[str, int]] = {}
151
+ for outcome in outcomes:
152
+ bucket = by_class.setdefault(outcome.case_class, Counter())
153
+ bucket["cases"] += 1
154
+ bucket[outcome.outcome] += 1
155
+ if outcome.decision == outcome.expected_decision:
156
+ bucket["exact_decision"] += 1
157
+ by_decision: dict[str, dict[str, int]] = {}
158
+ for outcome in outcomes:
159
+ bucket = by_decision.setdefault(outcome.expected_decision, Counter())
160
+ bucket[outcome.decision] += 1
161
+
162
+ exact = sum(1 for o in outcomes if o.decision == o.expected_decision)
163
+ mismatches = tuple(
164
+ o
165
+ for o in outcomes
166
+ if o.decision != o.expected_decision or o.missing_rules or o.unexpected_rules
167
+ )
168
+ return DetectionResult(
169
+ cases=len(cases),
170
+ positives=sum(1 for c in cases if c.positive),
171
+ negatives=sum(1 for c in cases if not c.positive),
172
+ decision=decision,
173
+ decision_rates=decision.rates(),
174
+ exact_decision_matches=exact,
175
+ exact_decision_accuracy=exact / len(cases),
176
+ rules=rules,
177
+ rule_rates={rule: confusion.rates() for rule, confusion in rules.items()},
178
+ by_class={k: dict(v) for k, v in sorted(by_class.items())},
179
+ by_decision={k: dict(v) for k, v in sorted(by_decision.items())},
180
+ latency=LatencySummary.from_seconds(durations),
181
+ duration_ms=round(total * 1000, 3),
182
+ mismatches=mismatches,
183
+ )
@@ -0,0 +1,185 @@
1
+ """The benchmark manifest: what was measured, with what, on what.
2
+
3
+ A result without its environment cannot be compared or reproduced, so every
4
+ benchmark result embeds a :class:`BenchmarkManifest`. Values that cannot be
5
+ determined on a platform are recorded as ``None`` - never guessed.
6
+ """
7
+
8
+ import os
9
+ import platform
10
+ import re
11
+ import shlex
12
+ import sys
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ from pydantic import BaseModel, ConfigDict
17
+
18
+ from commitguard import __version__
19
+ from commitguard.policies.defaults import DEFAULT_POLICIES
20
+ from commitguard.rules.loader import builtin_rules_fingerprint
21
+ from commitguard.security.hashing import sha256_hex
22
+ from commitguard.utils.subprocess import run_command
23
+
24
+ MANIFEST_SCHEMA = 1
25
+
26
+
27
+ class BenchmarkManifest(BaseModel):
28
+ model_config = ConfigDict(frozen=True, extra="forbid")
29
+
30
+ schema_version: int = MANIFEST_SCHEMA
31
+ commitguard_version: str
32
+ git_version: str | None
33
+ source_revision: str | None = None # commit of the CommitGuard checkout, if run from one
34
+ source_dirty: bool | None = None # uncommitted changes in that checkout
35
+ python_version: str
36
+ python_implementation: str
37
+ operating_system: str
38
+ os_release: str
39
+ os_version: str
40
+ machine: str
41
+ cpu_model: str | None
42
+ cpu_count: int | None
43
+ memory_bytes: int | None
44
+ rules_version: str
45
+ policy_version: str
46
+ configuration_version: str
47
+ dataset_version: str | None
48
+ dataset_fingerprint: str | None
49
+ timestamp: datetime
50
+ command: str
51
+
52
+
53
+ def git_version() -> str | None:
54
+ try:
55
+ result = run_command(["git", "--version"], timeout=10)
56
+ except OSError:
57
+ return None
58
+ if result.returncode != 0:
59
+ return None
60
+ match = re.search(r"(\d+\.\d+(?:\.\d+)?)", result.stdout.decode("utf-8", "replace"))
61
+ return match.group(1) if match else None
62
+
63
+
64
+ def source_revision() -> tuple[str | None, bool | None]:
65
+ """The Git commit of the CommitGuard source checkout this code runs from, if any."""
66
+ package = Path(__file__).resolve().parents[1]
67
+ root = package.parents[1] if package.parent.name == "src" else None
68
+ if root is None or not (root / ".git").exists():
69
+ return None, None
70
+ try:
71
+ head = run_command(["git", "-C", str(root), "rev-parse", "HEAD"], timeout=10)
72
+ status = run_command(
73
+ ["git", "-C", str(root), "status", "--porcelain", "--untracked-files=no"], timeout=30
74
+ )
75
+ except OSError:
76
+ return None, None
77
+ if head.returncode != 0:
78
+ return None, None
79
+ dirty = bool(status.stdout.strip()) if status.returncode == 0 else None
80
+ return head.stdout.decode("ascii", "replace").strip(), dirty
81
+
82
+
83
+ def cpu_model() -> str | None:
84
+ """The CPU model name, from the operating system, or None if unavailable."""
85
+ system = platform.system()
86
+ try:
87
+ if system == "Linux":
88
+ text = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace")
89
+ for line in text.splitlines():
90
+ if line.lower().startswith("model name"):
91
+ return line.split(":", 1)[1].strip()
92
+ elif system == "Darwin":
93
+ result = run_command(["sysctl", "-n", "machdep.cpu.brand_string"], timeout=10)
94
+ if result.returncode == 0:
95
+ return result.stdout.decode("utf-8", "replace").strip() or None
96
+ elif system == "Windows":
97
+ name = os.environ.get("PROCESSOR_IDENTIFIER")
98
+ return name.strip() if name else None
99
+ except OSError:
100
+ return None
101
+ return platform.processor() or None
102
+
103
+
104
+ def memory_bytes() -> int | None:
105
+ """Physical memory, or None if the platform does not expose it without extra packages."""
106
+ system = platform.system()
107
+ try:
108
+ if system == "Linux":
109
+ for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
110
+ if line.startswith("MemTotal:"):
111
+ return int(line.split()[1]) * 1024
112
+ elif system == "Darwin":
113
+ result = run_command(["sysctl", "-n", "hw.memsize"], timeout=10)
114
+ if result.returncode == 0:
115
+ return int(result.stdout.strip())
116
+ elif system == "Windows":
117
+ return _windows_memory()
118
+ except (OSError, ValueError):
119
+ return None
120
+ return None
121
+
122
+
123
+ def _windows_memory() -> int | None: # pragma: no cover - exercised on Windows CI
124
+ import ctypes
125
+
126
+ class MemoryStatus(ctypes.Structure):
127
+ _fields_ = [
128
+ ("dwLength", ctypes.c_ulong),
129
+ ("dwMemoryLoad", ctypes.c_ulong),
130
+ ("ullTotalPhys", ctypes.c_ulonglong),
131
+ ("ullAvailPhys", ctypes.c_ulonglong),
132
+ ("ullTotalPageFile", ctypes.c_ulonglong),
133
+ ("ullAvailPageFile", ctypes.c_ulonglong),
134
+ ("ullTotalVirtual", ctypes.c_ulonglong),
135
+ ("ullAvailVirtual", ctypes.c_ulonglong),
136
+ ("sullAvailExtendedVirtual", ctypes.c_ulonglong),
137
+ ]
138
+
139
+ status = MemoryStatus()
140
+ status.dwLength = ctypes.sizeof(MemoryStatus)
141
+ if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): # type: ignore[attr-defined]
142
+ return None
143
+ return int(status.ullTotalPhys)
144
+
145
+
146
+ def default_policy_version() -> str:
147
+ """Fingerprint of the built-in policy set (the policy the benchmarks evaluate)."""
148
+ canonical = ";".join(
149
+ f"{policy.id}={policy.action.value}:{int(policy.enabled)}"
150
+ for policy in sorted(DEFAULT_POLICIES.values(), key=lambda p: p.id)
151
+ )
152
+ return sha256_hex(canonical.encode("utf-8"))
153
+
154
+
155
+ def collect_manifest(
156
+ *,
157
+ dataset_version: str | None = None,
158
+ dataset_fingerprint: str | None = None,
159
+ configuration_version: str = "built-in defaults",
160
+ argv: list[str] | None = None,
161
+ ) -> BenchmarkManifest:
162
+ uname = platform.uname()
163
+ revision, dirty = source_revision()
164
+ return BenchmarkManifest(
165
+ commitguard_version=__version__,
166
+ git_version=git_version(),
167
+ source_revision=revision,
168
+ source_dirty=dirty,
169
+ python_version=platform.python_version(),
170
+ python_implementation=platform.python_implementation(),
171
+ operating_system=uname.system,
172
+ os_release=uname.release,
173
+ os_version=uname.version,
174
+ machine=uname.machine,
175
+ cpu_model=cpu_model(),
176
+ cpu_count=os.cpu_count(),
177
+ memory_bytes=memory_bytes(),
178
+ rules_version=builtin_rules_fingerprint(),
179
+ policy_version=default_policy_version(),
180
+ configuration_version=configuration_version,
181
+ dataset_version=dataset_version,
182
+ dataset_fingerprint=dataset_fingerprint,
183
+ timestamp=datetime.now(UTC),
184
+ command=shlex.join(["commitguard", *(sys.argv[1:] if argv is None else argv)]),
185
+ )
@@ -0,0 +1,108 @@
1
+ """Isolated Git workspaces for benchmarks.
2
+
3
+ Benchmarks run real ``git`` processes in temporary directories with a private
4
+ ``HOME`` and global configuration, so the developer's Git settings (hooks paths,
5
+ signing, aliases, credential helpers) can neither influence nor be modified by a
6
+ measurement.
7
+ """
8
+
9
+ import os
10
+ import shutil
11
+ import stat
12
+ import tempfile
13
+ import time
14
+ from collections.abc import Callable, Iterator, Mapping, Sequence
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from commitguard.utils.subprocess import CommandResult, run_command
20
+
21
+ GIT_TIMEOUT = 600.0
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Workspace:
26
+ root: Path
27
+ env: Mapping[str, str]
28
+
29
+ def git(
30
+ self, cwd: Path, *args: str, input_bytes: bytes | None = None, check: bool = True
31
+ ) -> CommandResult:
32
+ result = run_command(
33
+ ["git", *args],
34
+ cwd=cwd,
35
+ env_overrides=self.env,
36
+ input_bytes=input_bytes,
37
+ timeout=GIT_TIMEOUT,
38
+ )
39
+ if check and result.returncode != 0:
40
+ detail = result.stderr.decode("utf-8", "replace")[-500:]
41
+ raise RuntimeError(f"git {' '.join(args[:2])} failed ({result.returncode}): {detail}")
42
+ return result
43
+
44
+ def timed_git(
45
+ self, cwd: Path, *args: str, input_bytes: bytes | None = None
46
+ ) -> tuple[float, CommandResult]:
47
+ started = time.perf_counter()
48
+ result = self.git(cwd, *args, input_bytes=input_bytes, check=False)
49
+ return time.perf_counter() - started, result
50
+
51
+ def init(self, name: str, *, bare: bool = False) -> Path:
52
+ path = self.root / name
53
+ self.git(self.root, "init", "--quiet", *(["--bare"] if bare else []), str(path))
54
+ return path
55
+
56
+
57
+ def _on_rm_error(function: Callable[[str], object], path: str, _error: BaseException) -> None:
58
+ os.chmod(path, stat.S_IWRITE)
59
+ function(path)
60
+
61
+
62
+ @contextmanager
63
+ def workspace(prefix: str = "commitguard-bench-") -> Iterator[Workspace]:
64
+ root = Path(tempfile.mkdtemp(prefix=prefix)).resolve()
65
+ home = root / "home"
66
+ home.mkdir()
67
+ config = home / ".gitconfig"
68
+ config.write_text(
69
+ "[user]\n\tname = Benchmark Developer\n\temail = dev@example.com\n"
70
+ "[init]\n\tdefaultBranch = main\n"
71
+ "[commit]\n\tgpgsign = false\n"
72
+ "[core]\n\tautocrlf = false\n",
73
+ encoding="utf-8",
74
+ )
75
+ env = {
76
+ "HOME": str(home),
77
+ "USERPROFILE": str(home),
78
+ "XDG_CONFIG_HOME": str(home / ".config"),
79
+ "GIT_CONFIG_GLOBAL": str(config),
80
+ "GIT_CONFIG_NOSYSTEM": "1",
81
+ "GIT_TERMINAL_PROMPT": "0",
82
+ "LC_ALL": "C.UTF-8",
83
+ }
84
+ try:
85
+ yield Workspace(root=root, env=env)
86
+ finally:
87
+ shutil.rmtree(root, onexc=_on_rm_error)
88
+
89
+
90
+ def fast_import_stream(messages: Sequence[str], *, branch: str = "main") -> bytes:
91
+ """A ``git fast-import`` stream creating one commit per message on ``branch``."""
92
+ parts: list[bytes] = []
93
+ timestamp = 1_700_000_000
94
+ for index, message in enumerate(messages):
95
+ data = message.encode("utf-8")
96
+ content = f"change {index}\n".encode()
97
+ parts.append(f"commit refs/heads/{branch}\n".encode())
98
+ parts.append(f"mark :{index + 1}\n".encode())
99
+ parts.append(f"author Ada Lovelace <ada@example.com> {timestamp + index} +0000\n".encode())
100
+ parts.append(
101
+ f"committer Ada Lovelace <ada@example.com> {timestamp + index} +0000\n".encode()
102
+ )
103
+ parts.append(f"data {len(data)}\n".encode() + data + b"\n")
104
+ if index:
105
+ parts.append(f"from :{index}\n".encode())
106
+ parts.append(f"M 100644 inline file.txt\ndata {len(content)}\n".encode() + content)
107
+ parts.append(b"\n")
108
+ return b"".join(parts)