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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|