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
commitguard/git/hooks.py
ADDED
|
@@ -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
|
commitguard/git/push.py
ADDED
|
@@ -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
|