autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
"""The controller's own git transport (ADR 0004 D6.1-D6.4, D7.1-D7.5, D12.3).
|
|
2
|
+
|
|
3
|
+
In Wave 1 the agent can write everything the shared repository holds: its
|
|
4
|
+
configuration (``remote.origin.pushurl``, ``url.*.insteadOf``,
|
|
5
|
+
``core.sshCommand``, ``core.hooksPath``, ``include.path``), its hooks, its
|
|
6
|
+
refs (``refs/replace/*``), its graft and shallow files, the commit-graph in
|
|
7
|
+
the object store, and the object files themselves. This module is how the
|
|
8
|
+
controller still fetches, reads and pushes commits without any of that
|
|
9
|
+
steering it.
|
|
10
|
+
|
|
11
|
+
- **A controller-owned git directory per operation (D7.1).** Every fetch,
|
|
12
|
+
push and commit read runs with ``GIT_DIR`` set to a fresh private
|
|
13
|
+
directory (``tempfile.mkdtemp``, mode 0700, outside any repository) that
|
|
14
|
+
this module writes itself (``HEAD``, ``refs/``, ``objects/`` and a
|
|
15
|
+
``config`` that turns off gc, maintenance and the commit-graph) and
|
|
16
|
+
removes afterwards, whatever happened. ``GIT_OBJECT_DIRECTORY`` points at
|
|
17
|
+
the shared object store, so fetched objects land where the worktrees read
|
|
18
|
+
them and candidates are read from there. The object format comes from the
|
|
19
|
+
SHAs involved (40 or 64 hex digits), never from the shared configuration.
|
|
20
|
+
The directory has no hooks, no refs, no graft and no shallow file, so none
|
|
21
|
+
of the four substitution mechanisms of D7.5 is reachable from it except
|
|
22
|
+
the commit-graph, which ``core.commitGraph=false`` turns off.
|
|
23
|
+
- **No other configuration.** ``GIT_CONFIG_NOSYSTEM=1`` and
|
|
24
|
+
``GIT_CONFIG_GLOBAL=/dev/null``; the child starts from an explicit
|
|
25
|
+
allow-list of the controller's environment that names no ``GIT_*``
|
|
26
|
+
variable, so a ``GIT_CONFIG_COUNT``/``GIT_CONFIG_PARAMETERS``/``GIT_DIR``
|
|
27
|
+
in the operator's shell never reaches it. Hooks, the file-system monitor,
|
|
28
|
+
replacement objects, the commit-graph, automatic gc and maintenance and
|
|
29
|
+
every transport but the remote's own scheme are switched off again at
|
|
30
|
+
command-line precedence, so a key added to the private config by mistake
|
|
31
|
+
could not turn one of them back on.
|
|
32
|
+
- **An explicit URL and the credential from ``gh`` (D7.1).** The remote is a
|
|
33
|
+
:class:`GitRemote` URL (``https://github.com/<owner>/<repo>.git`` in
|
|
34
|
+
production), never ``origin``; no ``pushurl``, ``insteadOf`` or SSH
|
|
35
|
+
setting can apply because no repository configuration is read. The
|
|
36
|
+
credential comes from the operator's existing ``gh`` login through
|
|
37
|
+
``gh auth git-credential``, named at command-line precedence after the
|
|
38
|
+
helper list is reset, so it never appears in argv, a URL, a log or state.
|
|
39
|
+
- **A SHA reads as its own bytes (D7.5).** :meth:`GitTransport.read_commit`
|
|
40
|
+
re-hashes what git returns in the SHA's own object format and refuses a
|
|
41
|
+
mismatch; parents and messages come only from those authenticated bytes.
|
|
42
|
+
:meth:`GitTransport.prove_range` computes the published range itself: it
|
|
43
|
+
walks authenticated parent links from the candidate and stops only where
|
|
44
|
+
GitHub's answer (``in_base_history``) says a commit is in the base's
|
|
45
|
+
history, so nothing below the range is read and a rewritten ancestor of
|
|
46
|
+
the base cannot shrink it. Every failure fails closed with
|
|
47
|
+
:class:`GitTransportError`; nothing is ever read as "allowed".
|
|
48
|
+
- **Pushes are compare-and-swap fast-forwards only (D6.1-D6.3).** An exact
|
|
49
|
+
SHA to an exact ``refs/heads/<branch>``, never the default branch, with a
|
|
50
|
+
lease on the expected old value ("absent" or a full SHA) and the
|
|
51
|
+
fast-forward proven here first, because a lease that holds still permits
|
|
52
|
+
a non-fast-forward. No ``--force``, no ``+``, no delete.
|
|
53
|
+
|
|
54
|
+
Fetches write objects only (D6.4): no ref is created or moved anywhere, the
|
|
55
|
+
private directory included, so AutoForge owns no ref lifecycle. A fetch is a
|
|
56
|
+
controller write to the shared object store, so it runs under the shared-Git
|
|
57
|
+
lock (L1, ADR 0008 D12.1), taken inside :meth:`GitTransport.fetch` itself
|
|
58
|
+
from the factory every transport is built with, so that no caller can miss
|
|
59
|
+
it; a push writes nothing to the shared store and takes none.
|
|
60
|
+
|
|
61
|
+
Every process runs through the executor (``runner``); this module has no
|
|
62
|
+
workflow semantics beyond the rules above, and it never talks to the GitHub
|
|
63
|
+
API itself: GitHub's ancestry answers arrive through ``in_base_history``,
|
|
64
|
+
which the engine binds to a typed read of the GitHub client.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import hashlib
|
|
70
|
+
import os
|
|
71
|
+
import re
|
|
72
|
+
import shlex
|
|
73
|
+
import shutil
|
|
74
|
+
import tempfile
|
|
75
|
+
from collections import deque
|
|
76
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
77
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
78
|
+
from dataclasses import dataclass
|
|
79
|
+
from enum import Enum
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
|
|
82
|
+
from .errors import ExecutionError, GitLockTimeout, GitTransportError, LockError
|
|
83
|
+
from .executor import ExecutionRequest, ExecutionResult, execute
|
|
84
|
+
from .redaction import redact
|
|
85
|
+
|
|
86
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
87
|
+
|
|
88
|
+
# How many commits one range walk may read before it fails closed. A Wave 1
|
|
89
|
+
# published range is one issue's work plus whatever default-branch commits
|
|
90
|
+
# the candidate merged since the recorded base; a thousand is far above
|
|
91
|
+
# that. Each commit read costs one git process and one GitHub ancestry
|
|
92
|
+
# question, so the bound is also what bounds the walk's time and API use.
|
|
93
|
+
MAX_RANGE_COMMITS = 1000
|
|
94
|
+
|
|
95
|
+
# A network operation may move a large pack; a local object read is small.
|
|
96
|
+
NETWORK_TIMEOUT_SECONDS = 600
|
|
97
|
+
LOCAL_TIMEOUT_SECONDS = 120
|
|
98
|
+
|
|
99
|
+
# Hooks and the file-system monitor are off at command-line precedence for
|
|
100
|
+
# every controller git process (D7.3); replacement objects and the
|
|
101
|
+
# commit-graph too, so an object read by SHA reads that SHA (D7.5). The
|
|
102
|
+
# processes that run in the operator's repository (the engine's worktree
|
|
103
|
+
# creation and identity reads, the workspace reader, the lock's common-dir
|
|
104
|
+
# read, the pre-merge export) take them through :func:`local_git_request`.
|
|
105
|
+
LOCAL_GIT_SWITCHES: tuple[str, ...] = (
|
|
106
|
+
"--no-replace-objects",
|
|
107
|
+
"-c",
|
|
108
|
+
"core.commitGraph=false",
|
|
109
|
+
"-c",
|
|
110
|
+
"core.hooksPath=/dev/null",
|
|
111
|
+
"-c",
|
|
112
|
+
"core.fsmonitor=false",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Automatic maintenance off for one controller write to the shared store
|
|
116
|
+
# (ADR 0008 D12.3): neither ``git gc --auto`` nor ``git maintenance run
|
|
117
|
+
# --auto`` may start there from a controller write. Verified against git
|
|
118
|
+
# 2.56 (tests/test_lock_domains.py): a fetch into a store past
|
|
119
|
+
# ``gc.autoPackLimit`` repacks it without these options and leaves it alone
|
|
120
|
+
# with them; ``worktree add`` and ``index-pack`` start no maintenance either
|
|
121
|
+
# way and carry them so that a later git cannot change that. Every fetch
|
|
122
|
+
# (with ``--no-auto-gc`` as well), the agent worktree's ``worktree add`` and
|
|
123
|
+
# the strict import's ``index-pack`` carry both.
|
|
124
|
+
NO_AUTO_MAINTENANCE: tuple[str, ...] = ("-c", "gc.auto=0", "-c", "maintenance.auto=false")
|
|
125
|
+
|
|
126
|
+
# The environment a controller git process that holds no credential starts
|
|
127
|
+
# from (D7.3): ``PATH`` to find git, the locale, ``TMPDIR`` for git's
|
|
128
|
+
# temporary files, and ``HOME`` / ``XDG_CONFIG_HOME`` so a process in the
|
|
129
|
+
# operator's repository still reads the operator's global configuration
|
|
130
|
+
# (``safe.directory`` lives there). No ``GIT_*`` variable and no token: what
|
|
131
|
+
# such a process runs (a filter driver during a worktree's checkout) has no
|
|
132
|
+
# more authority than the agent.
|
|
133
|
+
LOCAL_GIT_ENV_ALLOWLIST: tuple[str, ...] = (
|
|
134
|
+
"PATH",
|
|
135
|
+
"LANG",
|
|
136
|
+
"LANGUAGE",
|
|
137
|
+
"LC_*",
|
|
138
|
+
"TMPDIR",
|
|
139
|
+
"HOME",
|
|
140
|
+
"XDG_CONFIG_HOME",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Layered over :data:`LOCAL_GIT_ENV_ALLOWLIST`: replacement objects are off
|
|
144
|
+
# by environment as well as by switch, so a nested git process (one a hook
|
|
145
|
+
# or filter driver starts) inherits it too.
|
|
146
|
+
LOCAL_GIT_ENV: dict[str, str] = {"GIT_NO_REPLACE_OBJECTS": "1"}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def local_git_request(
|
|
150
|
+
args: Sequence[str],
|
|
151
|
+
*,
|
|
152
|
+
cwd: str | None = None,
|
|
153
|
+
env: dict[str, str] | None = None,
|
|
154
|
+
timeout_seconds: int = LOCAL_TIMEOUT_SECONDS,
|
|
155
|
+
) -> ExecutionRequest:
|
|
156
|
+
"""One controller git process in the operator's repository (D7.3, D7.5).
|
|
157
|
+
|
|
158
|
+
Hooks, the file-system monitor, replacement objects and the commit-graph
|
|
159
|
+
are off at command-line precedence, and the child starts from
|
|
160
|
+
:data:`LOCAL_GIT_ENV_ALLOWLIST`: no token and no ``GIT_*`` variable of
|
|
161
|
+
the operator's shell reaches it or anything it starts. The repository
|
|
162
|
+
itself is still the one ``cwd`` (or a ``-C`` in ``args``) names, so a
|
|
163
|
+
tree read or a ``worktree add`` reads the operator's objects and refs.
|
|
164
|
+
"""
|
|
165
|
+
return ExecutionRequest(
|
|
166
|
+
command=["git", *LOCAL_GIT_SWITCHES, *args],
|
|
167
|
+
cwd=cwd,
|
|
168
|
+
env={**LOCAL_GIT_ENV, **(env or {})},
|
|
169
|
+
env_allowlist=LOCAL_GIT_ENV_ALLOWLIST,
|
|
170
|
+
timeout_seconds=timeout_seconds,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# The environment of a network operation (D7.1): ``PATH`` and the locale;
|
|
175
|
+
# the standard proxy variables (curl honours both spellings) and CA
|
|
176
|
+
# locations, so the operator's network setup still works; and exactly what
|
|
177
|
+
# ``gh auth git-credential`` needs to find the operator's existing ``gh``
|
|
178
|
+
# login: ``HOME`` / ``XDG_CONFIG_HOME`` / ``GH_CONFIG_DIR`` for its
|
|
179
|
+
# configuration, ``XDG_RUNTIME_DIR`` / ``DBUS_SESSION_BUS_ADDRESS`` for the
|
|
180
|
+
# system keyring, and the token variables ``gh`` reads when the operator
|
|
181
|
+
# authenticates it that way. No ``GIT_*`` variable: git's own configuration
|
|
182
|
+
# and object locations are set by this module alone.
|
|
183
|
+
NETWORK_GIT_ENV_ALLOWLIST: tuple[str, ...] = (
|
|
184
|
+
"PATH",
|
|
185
|
+
"LANG",
|
|
186
|
+
"LANGUAGE",
|
|
187
|
+
"LC_*",
|
|
188
|
+
"HTTP_PROXY",
|
|
189
|
+
"HTTPS_PROXY",
|
|
190
|
+
"NO_PROXY",
|
|
191
|
+
"ALL_PROXY",
|
|
192
|
+
"http_proxy",
|
|
193
|
+
"https_proxy",
|
|
194
|
+
"no_proxy",
|
|
195
|
+
"all_proxy",
|
|
196
|
+
"SSL_CERT_FILE",
|
|
197
|
+
"SSL_CERT_DIR",
|
|
198
|
+
"CURL_CA_BUNDLE",
|
|
199
|
+
"HOME",
|
|
200
|
+
"XDG_CONFIG_HOME",
|
|
201
|
+
"XDG_RUNTIME_DIR",
|
|
202
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
203
|
+
"GH_CONFIG_DIR",
|
|
204
|
+
"GH_HOST",
|
|
205
|
+
"GH_TOKEN",
|
|
206
|
+
"GITHUB_TOKEN",
|
|
207
|
+
"GH_ENTERPRISE_TOKEN",
|
|
208
|
+
"GITHUB_ENTERPRISE_TOKEN",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
_SHA1_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
212
|
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
213
|
+
# GitHub owner and repository names: letters, digits, ``_``, ``.``, ``-``.
|
|
214
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
|
|
215
|
+
# One ref path component this module accepts: a deliberately small subset of
|
|
216
|
+
# what ``git check-ref-format`` allows. A name outside it is refused (fails
|
|
217
|
+
# closed), never normalised.
|
|
218
|
+
_REF_COMPONENT_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]*$")
|
|
219
|
+
_MAX_REF_LENGTH = 255
|
|
220
|
+
# Parsing ``https://host/path`` and ``file:///path``. No userinfo, port,
|
|
221
|
+
# query or fragment: a URL carries no credential and nothing git could read
|
|
222
|
+
# as an option.
|
|
223
|
+
_HTTPS_URL_RE = re.compile(r"^https://[A-Za-z0-9.-]+(?:/[A-Za-z0-9_.~-]+)+$")
|
|
224
|
+
_FILE_URL_RE = re.compile(r"^file:///[^\s?#@]*$")
|
|
225
|
+
_GITHUB_HOST = "github.com"
|
|
226
|
+
|
|
227
|
+
_DETAIL_CHARS = 500
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _object_format(sha: str) -> str:
|
|
231
|
+
"""``sha1`` or ``sha256`` for a full lowercase hex id; anything else is refused."""
|
|
232
|
+
if _SHA1_RE.match(sha):
|
|
233
|
+
return "sha1"
|
|
234
|
+
if _SHA256_RE.match(sha):
|
|
235
|
+
return "sha256"
|
|
236
|
+
raise GitTransportError(f"{sha!r} is not a full lowercase hexadecimal object id")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _format_of(shas: Sequence[str], default: str) -> str:
|
|
240
|
+
"""The one object format the SHAs share, or ``default`` when there are none."""
|
|
241
|
+
formats = {_object_format(sha) for sha in shas}
|
|
242
|
+
if len(formats) > 1:
|
|
243
|
+
raise GitTransportError("object ids of two different formats in one operation")
|
|
244
|
+
return formats.pop() if formats else default
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _valid_ref_path(name: str) -> bool:
|
|
248
|
+
"""Whether ``name`` is ``component(/component)*`` in the accepted subset.
|
|
249
|
+
|
|
250
|
+
No ``..``, no component starting with ``.`` or ending with ``.`` or
|
|
251
|
+
``.lock``, no empty component, no character outside
|
|
252
|
+
``[A-Za-z0-9._-]``, at most :data:`_MAX_REF_LENGTH` characters.
|
|
253
|
+
"""
|
|
254
|
+
if not name or len(name) > _MAX_REF_LENGTH or ".." in name:
|
|
255
|
+
return False
|
|
256
|
+
for component in name.split("/"):
|
|
257
|
+
if not _REF_COMPONENT_RE.match(component):
|
|
258
|
+
return False
|
|
259
|
+
if component.endswith(".") or component.endswith(".lock"):
|
|
260
|
+
return False
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def valid_branch_name(branch: str) -> bool:
|
|
265
|
+
"""Whether ``branch`` is a plain branch name this transport will push to.
|
|
266
|
+
|
|
267
|
+
A short name (``autoforge/160``), never a full ref (``refs/...``), never
|
|
268
|
+
``HEAD``; see :func:`_valid_ref_path` for the character rules.
|
|
269
|
+
"""
|
|
270
|
+
if branch.startswith("refs/") or branch == "HEAD":
|
|
271
|
+
return False
|
|
272
|
+
return _valid_ref_path(branch)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _valid_fetch_revision(revision: str) -> bool:
|
|
276
|
+
"""A full object id, or a full ref name (``refs/pull/7/head``).
|
|
277
|
+
|
|
278
|
+
Never a refspec: no ``:`` (a destination), no leading ``+`` (a force),
|
|
279
|
+
no ``^``/``~``/glob, nothing that starts with ``-``.
|
|
280
|
+
"""
|
|
281
|
+
if _SHA1_RE.match(revision) or _SHA256_RE.match(revision):
|
|
282
|
+
return True
|
|
283
|
+
return revision.startswith("refs/") and _valid_ref_path(revision)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _tail(result: ExecutionResult) -> str:
|
|
287
|
+
"""The end of a failed process's output, redacted, for an error message."""
|
|
288
|
+
text = (result.stderr or result.stdout or "").strip()
|
|
289
|
+
return redact(text[-_DETAIL_CHARS:])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(frozen=True)
|
|
293
|
+
class GitRemote:
|
|
294
|
+
"""The one URL a network operation talks to; never a remote name.
|
|
295
|
+
|
|
296
|
+
``https://`` in production (built with :meth:`https`); ``file://`` for a
|
|
297
|
+
local bare repository (tests). No userinfo is accepted, so a credential
|
|
298
|
+
can never travel in the URL; the credential comes from ``gh``.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
url: str
|
|
302
|
+
|
|
303
|
+
def __post_init__(self) -> None:
|
|
304
|
+
if not (_HTTPS_URL_RE.match(self.url) or _FILE_URL_RE.match(self.url)):
|
|
305
|
+
raise GitTransportError(
|
|
306
|
+
"a git remote must be an https:// URL with no credential, port, query or "
|
|
307
|
+
f"fragment, or a file:/// URL; got {redact(self.url)!r}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def scheme(self) -> str:
|
|
312
|
+
"""The URL's scheme, which is the one transport git is allowed to use."""
|
|
313
|
+
return self.url.split("://", 1)[0]
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def https(cls, repository: str) -> GitRemote:
|
|
317
|
+
"""``https://github.com/<owner>/<repo>.git`` for a verified ``owner/repo``."""
|
|
318
|
+
parts = repository.split("/")
|
|
319
|
+
if len(parts) != 2 or not all(
|
|
320
|
+
_NAME_RE.match(part) and part not in (".", "..") for part in parts
|
|
321
|
+
):
|
|
322
|
+
raise GitTransportError(f"{repository!r} is not an owner/repo repository name")
|
|
323
|
+
owner, repo = parts
|
|
324
|
+
return cls(url=f"https://{_GITHUB_HOST}/{owner}/{repo}.git")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True)
|
|
328
|
+
class CommitRecord:
|
|
329
|
+
"""A commit read from bytes that hash to its id (D7.5)."""
|
|
330
|
+
|
|
331
|
+
sha: str
|
|
332
|
+
parents: tuple[str, ...]
|
|
333
|
+
# Decoded from the authenticated bytes (UTF-8, invalid bytes replaced).
|
|
334
|
+
message: str
|
|
335
|
+
# The tree the authenticated bytes name: a content address, so the
|
|
336
|
+
# store itself proves the commit and the tree correspond (ADR 0006 D2.2).
|
|
337
|
+
tree: str = ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass(frozen=True)
|
|
341
|
+
class RangeProof:
|
|
342
|
+
"""The published range ``base..candidate`` as the walk of D7.5 proved it."""
|
|
343
|
+
|
|
344
|
+
base: str
|
|
345
|
+
candidate: str
|
|
346
|
+
# Every commit the walk included, in walk (breadth-first) order: those
|
|
347
|
+
# reachable from the candidate through authenticated parent links that
|
|
348
|
+
# are neither the base nor reported by GitHub in the base's history.
|
|
349
|
+
commits: tuple[CommitRecord, ...]
|
|
350
|
+
# True iff the walk reached ``base`` itself: the candidate descends from
|
|
351
|
+
# (or is) the base. A candidate equal to the base has an empty range.
|
|
352
|
+
reached_base: bool
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class PushOutcome(Enum):
|
|
356
|
+
"""What one push attempt established about the remote ref.
|
|
357
|
+
|
|
358
|
+
Only the read-back through the GitHub client is authoritative for the
|
|
359
|
+
effect record; this is what git reported, classified.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
# The remote accepted the update: a new branch, or a fast-forward.
|
|
363
|
+
PUSHED = "pushed"
|
|
364
|
+
# The remote ref already pointed at the candidate; nothing was sent. Git
|
|
365
|
+
# reports this before it checks the lease, so it is the outcome of a
|
|
366
|
+
# push that already landed (a save lost after the push) whatever the
|
|
367
|
+
# expected old value was.
|
|
368
|
+
UP_TO_DATE = "up_to_date"
|
|
369
|
+
# Definitive: the remote ref was not at the expected old value (a human
|
|
370
|
+
# pushed meanwhile, or the branch exists when it was expected absent),
|
|
371
|
+
# so git refused before updating anything.
|
|
372
|
+
LEASE_REJECTED = "lease_rejected"
|
|
373
|
+
# Definitive: the remote refused the update for its own reason (branch
|
|
374
|
+
# protection, a server-side hook); ``detail`` carries git's reason.
|
|
375
|
+
REMOTE_REJECTED = "remote_rejected"
|
|
376
|
+
# Ambiguous: a timeout, a transport failure, a failed capture or output
|
|
377
|
+
# this module cannot classify. The push may or may not have landed. The
|
|
378
|
+
# caller reconciles by reading the remote ref, never by pushing again
|
|
379
|
+
# blindly.
|
|
380
|
+
UNKNOWN = "unknown"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class PushResult:
|
|
385
|
+
outcome: PushOutcome
|
|
386
|
+
branch: str
|
|
387
|
+
sha: str
|
|
388
|
+
expected_old: str | None
|
|
389
|
+
# One redacted, bounded line for a log or a BLOCKED reason; never
|
|
390
|
+
# authoritative and never parsed.
|
|
391
|
+
detail: str
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def published_range_problem(
|
|
395
|
+
proof: RangeProof, message_problem: Callable[[str], str | None]
|
|
396
|
+
) -> str | None:
|
|
397
|
+
"""Run the commit-message policy over every commit of a proven range (D8.5).
|
|
398
|
+
|
|
399
|
+
``message_problem`` is the caller's policy (closing keywords naming
|
|
400
|
+
another issue, credential-shaped strings); it returns a reason or None.
|
|
401
|
+
The first refusal is returned naming the commit's SHA, never quoting the
|
|
402
|
+
message, so the reason must not quote it either. Whether the candidate
|
|
403
|
+
descends from the base is :attr:`RangeProof.reached_base`, which the
|
|
404
|
+
caller checks separately.
|
|
405
|
+
"""
|
|
406
|
+
for commit in proof.commits:
|
|
407
|
+
problem = message_problem(commit.message)
|
|
408
|
+
if problem:
|
|
409
|
+
return (
|
|
410
|
+
f"commit {commit.sha} in the published range {proof.base}..{proof.candidate}: "
|
|
411
|
+
f"{problem}"
|
|
412
|
+
)
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class GitTransport:
|
|
417
|
+
"""Fetch, read and push commits in a controller-owned git context.
|
|
418
|
+
|
|
419
|
+
``object_directory`` is the shared object store (``<git common
|
|
420
|
+
dir>/objects``). ``in_base_history(base, sha)`` is GitHub's answer to
|
|
421
|
+
whether ``sha`` is in ``base``'s history (``behind`` or ``identical``
|
|
422
|
+
when comparing ``base...sha``; False for ``ahead``, ``diverged`` and a
|
|
423
|
+
commit GitHub does not hold); any exception it raises fails the walk
|
|
424
|
+
closed. ``object_format`` is used only when an operation names no SHA
|
|
425
|
+
(a fetch of refs alone); otherwise the SHAs' own format is used.
|
|
426
|
+
``git_lock(operation)`` returns the shared-Git lock (L1) of the store's
|
|
427
|
+
common dir, a context manager :meth:`fetch` holds around its git
|
|
428
|
+
process (ADR 0008 D12.1); it is required, so no transport fetches
|
|
429
|
+
without it.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
def __init__(
|
|
433
|
+
self,
|
|
434
|
+
*,
|
|
435
|
+
object_directory: Path,
|
|
436
|
+
remote: GitRemote,
|
|
437
|
+
gh_command: str = "gh",
|
|
438
|
+
in_base_history: Callable[[str, str], bool],
|
|
439
|
+
git_lock: Callable[[str], AbstractContextManager[object]],
|
|
440
|
+
runner: Runner | None = None,
|
|
441
|
+
max_range_commits: int = MAX_RANGE_COMMITS,
|
|
442
|
+
object_format: str = "sha1",
|
|
443
|
+
) -> None:
|
|
444
|
+
if max_range_commits < 1:
|
|
445
|
+
raise GitTransportError(f"max_range_commits must be >= 1, got {max_range_commits}")
|
|
446
|
+
if object_format not in ("sha1", "sha256"):
|
|
447
|
+
raise GitTransportError(f"unknown object format {object_format!r}")
|
|
448
|
+
self._objects = Path(object_directory).resolve()
|
|
449
|
+
self._remote = remote
|
|
450
|
+
self._gh_command = gh_command
|
|
451
|
+
self._in_base_history = in_base_history
|
|
452
|
+
self._git_lock = git_lock
|
|
453
|
+
self._runner: Runner = runner or execute
|
|
454
|
+
self._max_range_commits = max_range_commits
|
|
455
|
+
self._default_format = object_format
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def remote(self) -> GitRemote:
|
|
459
|
+
return self._remote
|
|
460
|
+
|
|
461
|
+
# -- the per-operation context ------------------------------------------------------
|
|
462
|
+
@contextmanager
|
|
463
|
+
def _git_dir(self, object_format: str) -> Iterator[Path]:
|
|
464
|
+
"""A fresh minimal git directory for one operation, removed afterwards.
|
|
465
|
+
|
|
466
|
+
Written here, not by ``git init``, so no template (and no template
|
|
467
|
+
hook) is copied in; it has no hooks directory at all.
|
|
468
|
+
"""
|
|
469
|
+
try:
|
|
470
|
+
root = Path(tempfile.mkdtemp(prefix="autoforge-git-"))
|
|
471
|
+
except OSError as exc:
|
|
472
|
+
raise GitTransportError(f"cannot create a private git directory: {exc}") from exc
|
|
473
|
+
try:
|
|
474
|
+
(root / "refs").mkdir()
|
|
475
|
+
(root / "objects").mkdir()
|
|
476
|
+
(root / "HEAD").write_text("ref: refs/heads/autoforge-unborn\n", encoding="ascii")
|
|
477
|
+
(root / "config").write_text(_private_config(object_format), encoding="ascii")
|
|
478
|
+
yield root
|
|
479
|
+
except OSError as exc:
|
|
480
|
+
raise GitTransportError(f"cannot prepare a private git directory: {exc}") from exc
|
|
481
|
+
finally:
|
|
482
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
483
|
+
|
|
484
|
+
def _gh_path(self) -> str:
|
|
485
|
+
"""The absolute path of the controller's ``gh``; refused when absent."""
|
|
486
|
+
found = shutil.which(self._gh_command)
|
|
487
|
+
if found is None:
|
|
488
|
+
raise GitTransportError(
|
|
489
|
+
f"the GitHub CLI {self._gh_command!r} was not found; the controller's git "
|
|
490
|
+
"transport takes its credential from gh"
|
|
491
|
+
)
|
|
492
|
+
return os.path.abspath(found)
|
|
493
|
+
|
|
494
|
+
def _request(
|
|
495
|
+
self,
|
|
496
|
+
git_dir: Path,
|
|
497
|
+
args: Sequence[str],
|
|
498
|
+
*,
|
|
499
|
+
gh_path: str | None,
|
|
500
|
+
stdin: bytes | None = None,
|
|
501
|
+
) -> ExecutionRequest:
|
|
502
|
+
"""One git process in ``git_dir``; a network one when ``gh_path`` is given."""
|
|
503
|
+
# No promisor remote can be configured in the private directory, so a
|
|
504
|
+
# missing object is never fetched lazily behind this module's back.
|
|
505
|
+
switches = [*LOCAL_GIT_SWITCHES, *NO_AUTO_MAINTENANCE]
|
|
506
|
+
switches += ["-c", "protocol.allow=never"]
|
|
507
|
+
if gh_path is not None:
|
|
508
|
+
switches += ["-c", f"protocol.{self._remote.scheme}.allow=always"]
|
|
509
|
+
# Reset the helper list, then name gh's helper. Git runs a ``!``
|
|
510
|
+
# helper through the shell, hence the quoting of the path.
|
|
511
|
+
switches += [
|
|
512
|
+
"-c",
|
|
513
|
+
"credential.helper=",
|
|
514
|
+
"-c",
|
|
515
|
+
f"credential.helper=!{shlex.quote(gh_path)} auth git-credential",
|
|
516
|
+
"-c",
|
|
517
|
+
"credential.interactive=false",
|
|
518
|
+
]
|
|
519
|
+
env = {
|
|
520
|
+
"GIT_DIR": str(git_dir),
|
|
521
|
+
"GIT_OBJECT_DIRECTORY": str(self._objects),
|
|
522
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
523
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
524
|
+
"GIT_NO_REPLACE_OBJECTS": "1",
|
|
525
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
526
|
+
}
|
|
527
|
+
return ExecutionRequest(
|
|
528
|
+
command=["git", *switches, *args],
|
|
529
|
+
cwd=str(git_dir),
|
|
530
|
+
env=env,
|
|
531
|
+
env_allowlist=(
|
|
532
|
+
NETWORK_GIT_ENV_ALLOWLIST if gh_path is not None else LOCAL_GIT_ENV_ALLOWLIST
|
|
533
|
+
),
|
|
534
|
+
timeout_seconds=NETWORK_TIMEOUT_SECONDS
|
|
535
|
+
if gh_path is not None
|
|
536
|
+
else LOCAL_TIMEOUT_SECONDS,
|
|
537
|
+
stdin_data=stdin,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _run(self, request: ExecutionRequest, what: str) -> ExecutionResult:
|
|
541
|
+
try:
|
|
542
|
+
return self._runner(request)
|
|
543
|
+
except ExecutionError as exc:
|
|
544
|
+
raise GitTransportError(f"{what}: {redact(str(exc))}") from exc
|
|
545
|
+
|
|
546
|
+
# -- fetch ---------------------------------------------------------------------------
|
|
547
|
+
def fetch(self, revisions: Sequence[str]) -> None:
|
|
548
|
+
"""Fetch the objects of ``revisions`` into the shared store; no ref anywhere.
|
|
549
|
+
|
|
550
|
+
Each revision is a full SHA or a full ref name (``refs/pull/7/head``,
|
|
551
|
+
``refs/heads/main``). No destination is given, tags are not followed,
|
|
552
|
+
``FETCH_HEAD`` is not written and submodules are not touched, so no
|
|
553
|
+
ref is created or moved, the private directory's included (D6.4).
|
|
554
|
+
The git process runs under the shared-Git lock (L1, ADR 0008 D12.1);
|
|
555
|
+
a lock not obtained within its bound
|
|
556
|
+
(:class:`~autoforge.errors.GitLockTimeout`), or a lock entry that is
|
|
557
|
+
not the controller's file, is this fetch failing
|
|
558
|
+
(:class:`GitTransportError`), never retried here (D11.3).
|
|
559
|
+
"""
|
|
560
|
+
if isinstance(revisions, str):
|
|
561
|
+
raise GitTransportError("fetch takes a sequence of revisions, not one string")
|
|
562
|
+
wanted = list(revisions)
|
|
563
|
+
if not wanted:
|
|
564
|
+
raise GitTransportError("nothing to fetch")
|
|
565
|
+
for revision in wanted:
|
|
566
|
+
if not _valid_fetch_revision(revision):
|
|
567
|
+
raise GitTransportError(f"{revision!r} is not a full object id or ref name")
|
|
568
|
+
shas = [r for r in wanted if not r.startswith("refs/")]
|
|
569
|
+
object_format = _format_of(shas, self._default_format)
|
|
570
|
+
gh_path = self._gh_path()
|
|
571
|
+
shown = ", ".join(wanted)
|
|
572
|
+
with self._git_dir(object_format) as git_dir:
|
|
573
|
+
request = self._request(
|
|
574
|
+
git_dir,
|
|
575
|
+
[
|
|
576
|
+
"fetch",
|
|
577
|
+
"--no-tags",
|
|
578
|
+
"--no-write-fetch-head",
|
|
579
|
+
"--no-recurse-submodules",
|
|
580
|
+
"--no-auto-gc",
|
|
581
|
+
"--no-write-commit-graph",
|
|
582
|
+
"--quiet",
|
|
583
|
+
self._remote.url,
|
|
584
|
+
*wanted,
|
|
585
|
+
],
|
|
586
|
+
gh_path=gh_path,
|
|
587
|
+
)
|
|
588
|
+
try:
|
|
589
|
+
with self._git_lock(f"fetch {shown}"):
|
|
590
|
+
result = self._run(request, f"fetching {shown} from {self._remote.url} failed")
|
|
591
|
+
except (GitLockTimeout, LockError) as exc:
|
|
592
|
+
raise GitTransportError(
|
|
593
|
+
f"fetching {shown} from {self._remote.url} failed: {exc}"
|
|
594
|
+
) from exc
|
|
595
|
+
if result.timed_out:
|
|
596
|
+
raise GitTransportError(
|
|
597
|
+
f"fetching {shown} from {self._remote.url} timed out after "
|
|
598
|
+
f"{request.timeout_seconds}s"
|
|
599
|
+
)
|
|
600
|
+
if result.exit_code != 0:
|
|
601
|
+
raise GitTransportError(
|
|
602
|
+
f"fetching {shown} from {self._remote.url} failed (exit {result.exit_code}): "
|
|
603
|
+
f"{_tail(result)}"
|
|
604
|
+
)
|
|
605
|
+
if result.truncated:
|
|
606
|
+
raise GitTransportError(
|
|
607
|
+
f"fetching {shown}: git's output was truncated at the executor's capture bound"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# -- object reads --------------------------------------------------------------------
|
|
611
|
+
def _batch(self, sha: str, mode: str) -> ExecutionResult:
|
|
612
|
+
"""``git cat-file --batch`` / ``--batch-check`` for one SHA, in its own directory."""
|
|
613
|
+
with self._git_dir(_object_format(sha)) as git_dir:
|
|
614
|
+
request = self._request(
|
|
615
|
+
git_dir, ["cat-file", mode], gh_path=None, stdin=f"{sha}\n".encode("ascii")
|
|
616
|
+
)
|
|
617
|
+
result = self._run(request, f"reading object {sha} failed")
|
|
618
|
+
if result.timed_out:
|
|
619
|
+
raise GitTransportError(f"reading object {sha} timed out")
|
|
620
|
+
if result.truncated:
|
|
621
|
+
raise GitTransportError(
|
|
622
|
+
f"reading object {sha}: git's output was truncated at the capture bound"
|
|
623
|
+
)
|
|
624
|
+
return result
|
|
625
|
+
|
|
626
|
+
def has_commit(self, sha: str) -> bool:
|
|
627
|
+
"""Whether the shared store holds a commit under ``sha``, with no substitution.
|
|
628
|
+
|
|
629
|
+
No replacement ref, graft or commit-graph can make a missing commit
|
|
630
|
+
present: the check runs in a private directory with replacement
|
|
631
|
+
objects and the commit-graph off. Existence only; the bytes are
|
|
632
|
+
authenticated by :meth:`read_commit`.
|
|
633
|
+
"""
|
|
634
|
+
result = self._batch(sha, "--batch-check")
|
|
635
|
+
if result.exit_code != 0:
|
|
636
|
+
raise GitTransportError(
|
|
637
|
+
f"checking for commit {sha} failed (exit {result.exit_code}): {_tail(result)}"
|
|
638
|
+
)
|
|
639
|
+
header = result.stdout.rstrip("\n")
|
|
640
|
+
if header == f"{sha} missing":
|
|
641
|
+
return False
|
|
642
|
+
parts = header.split(" ")
|
|
643
|
+
if len(parts) != 3 or parts[0] != sha:
|
|
644
|
+
raise GitTransportError(f"unexpected reply checking for commit {sha}")
|
|
645
|
+
return parts[1] == "commit"
|
|
646
|
+
|
|
647
|
+
def _read_once(self, sha: str) -> CommitRecord | None:
|
|
648
|
+
"""The authenticated commit, or None when the store does not hold ``sha``."""
|
|
649
|
+
result = self._batch(sha, "--batch")
|
|
650
|
+
if result.exit_code != 0:
|
|
651
|
+
# A loose object git cannot even inflate, for example. Not
|
|
652
|
+
# "missing": it is not re-fetched, and it never reads as allowed.
|
|
653
|
+
raise GitTransportError(
|
|
654
|
+
f"reading commit {sha} failed (exit {result.exit_code}): {_tail(result)}"
|
|
655
|
+
)
|
|
656
|
+
header, newline, body = result.stdout.partition("\n")
|
|
657
|
+
if header == f"{sha} missing":
|
|
658
|
+
return None
|
|
659
|
+
parts = header.split(" ")
|
|
660
|
+
if not newline or len(parts) != 3 or parts[0] != sha or not parts[2].isdigit():
|
|
661
|
+
raise GitTransportError(f"unexpected reply reading commit {sha}")
|
|
662
|
+
if parts[1] != "commit":
|
|
663
|
+
raise GitTransportError(f"object {sha} is a {parts[1]}, not a commit")
|
|
664
|
+
size = int(parts[2])
|
|
665
|
+
# The executor captures text (UTF-8, invalid bytes replaced). Valid
|
|
666
|
+
# UTF-8 round-trips exactly; anything else changes length or hash and
|
|
667
|
+
# is refused below, never authenticated from a lossy copy.
|
|
668
|
+
data = body.encode("utf-8")
|
|
669
|
+
raw = data[:size]
|
|
670
|
+
lossy = "�" in body
|
|
671
|
+
if len(data) != size + 1 or data[size:] != b"\n":
|
|
672
|
+
if lossy:
|
|
673
|
+
raise GitTransportError(_lossy(sha))
|
|
674
|
+
raise GitTransportError(f"commit {sha}: git returned {len(data) - 1} bytes, not {size}")
|
|
675
|
+
if _hash_commit(raw, _object_format(sha)) != sha:
|
|
676
|
+
if lossy:
|
|
677
|
+
raise GitTransportError(_lossy(sha))
|
|
678
|
+
raise GitTransportError(
|
|
679
|
+
f"commit {sha}: the stored bytes do not hash to its id (a rewritten or "
|
|
680
|
+
"substituted object); it is not used"
|
|
681
|
+
)
|
|
682
|
+
return _parse_commit(sha, raw)
|
|
683
|
+
|
|
684
|
+
def read_local_commit(self, sha: str) -> CommitRecord | None:
|
|
685
|
+
"""``sha`` as its own bytes when the shared store holds it; never fetched.
|
|
686
|
+
|
|
687
|
+
For a candidate the controller validates (ADR 0006 D2.2, D10.5): one
|
|
688
|
+
that is not in the store is refused, not fetched by this read.
|
|
689
|
+
"""
|
|
690
|
+
_object_format(sha)
|
|
691
|
+
return self._read_once(sha)
|
|
692
|
+
|
|
693
|
+
def read_commit(self, sha: str) -> CommitRecord:
|
|
694
|
+
"""Read ``sha`` as its own bytes: re-hashed, parsed, never substituted.
|
|
695
|
+
|
|
696
|
+
A commit the store does not hold is fetched once by SHA (D6.4) and
|
|
697
|
+
read again; still missing is a :class:`GitTransportError`, never
|
|
698
|
+
"not an ancestor, so allowed". An id mismatch is not re-fetched.
|
|
699
|
+
"""
|
|
700
|
+
_object_format(sha)
|
|
701
|
+
record = self._read_once(sha)
|
|
702
|
+
if record is not None:
|
|
703
|
+
return record
|
|
704
|
+
try:
|
|
705
|
+
self.fetch([sha])
|
|
706
|
+
except GitTransportError as exc:
|
|
707
|
+
raise GitTransportError(
|
|
708
|
+
f"commit {sha} is not in the object store and fetching it again failed: {exc}"
|
|
709
|
+
) from exc
|
|
710
|
+
record = self._read_once(sha)
|
|
711
|
+
if record is None:
|
|
712
|
+
raise GitTransportError(f"commit {sha} is still missing after fetching it again")
|
|
713
|
+
return record
|
|
714
|
+
|
|
715
|
+
# -- the range walk ------------------------------------------------------------------
|
|
716
|
+
def _ask_in_base_history(self, base: str, sha: str) -> bool:
|
|
717
|
+
try:
|
|
718
|
+
answer = self._in_base_history(base, sha)
|
|
719
|
+
except Exception as exc: # any failure of the answer fails closed
|
|
720
|
+
raise GitTransportError(
|
|
721
|
+
f"GitHub's answer to whether {sha} is in the history of {base} is unavailable "
|
|
722
|
+
f"({type(exc).__name__}: {redact(str(exc))[:_DETAIL_CHARS]}); the range is not "
|
|
723
|
+
"proven"
|
|
724
|
+
) from exc
|
|
725
|
+
if answer is True or answer is False:
|
|
726
|
+
return answer
|
|
727
|
+
raise GitTransportError(
|
|
728
|
+
f"GitHub's answer to whether {sha} is in the history of {base} is not a boolean"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def prove_range(self, base: str, candidate: str) -> RangeProof:
|
|
732
|
+
"""Prove the published range ``base..candidate`` (D7.5).
|
|
733
|
+
|
|
734
|
+
Breadth-first from the candidate. A commit reached is the base (stop
|
|
735
|
+
there, leave it out, :attr:`RangeProof.reached_base`), or one GitHub
|
|
736
|
+
reports in the base's history (stop there, leave it out, do not read
|
|
737
|
+
it or its parents), or else part of the range: it is read as its own
|
|
738
|
+
bytes and its authenticated parents are walked. Nothing below an
|
|
739
|
+
excluded commit is read, so no rewritten ancestor of the base can
|
|
740
|
+
hide a commit from the range. Reading more than ``max_range_commits``
|
|
741
|
+
commits, an id mismatch, a commit still missing after a re-fetch and
|
|
742
|
+
an unavailable GitHub answer each raise :class:`GitTransportError`.
|
|
743
|
+
"""
|
|
744
|
+
if _object_format(base) != _object_format(candidate):
|
|
745
|
+
raise GitTransportError(
|
|
746
|
+
f"the base {base} and the candidate {candidate} use different object formats"
|
|
747
|
+
)
|
|
748
|
+
commits: list[CommitRecord] = []
|
|
749
|
+
reached_base = False
|
|
750
|
+
seen = {candidate}
|
|
751
|
+
pending = deque([candidate])
|
|
752
|
+
while pending:
|
|
753
|
+
sha = pending.popleft()
|
|
754
|
+
if sha == base:
|
|
755
|
+
reached_base = True
|
|
756
|
+
continue
|
|
757
|
+
if self._ask_in_base_history(base, sha):
|
|
758
|
+
continue
|
|
759
|
+
if len(commits) >= self._max_range_commits:
|
|
760
|
+
raise GitTransportError(
|
|
761
|
+
f"the range {base}..{candidate} holds more than {self._max_range_commits} "
|
|
762
|
+
"commits; it is not proven"
|
|
763
|
+
)
|
|
764
|
+
record = self.read_commit(sha)
|
|
765
|
+
commits.append(record)
|
|
766
|
+
for parent in record.parents:
|
|
767
|
+
if parent not in seen:
|
|
768
|
+
seen.add(parent)
|
|
769
|
+
pending.append(parent)
|
|
770
|
+
return RangeProof(
|
|
771
|
+
base=base, candidate=candidate, commits=tuple(commits), reached_base=reached_base
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def descends_from(self, candidate: str, ancestor: str) -> bool:
|
|
775
|
+
"""Whether ``candidate`` is ``ancestor`` or descends from it (D7.5).
|
|
776
|
+
|
|
777
|
+
True only when the walk for ``ancestor..candidate`` reaches
|
|
778
|
+
``ancestor``; a walk that cannot be completed raises instead.
|
|
779
|
+
"""
|
|
780
|
+
return self.prove_range(ancestor, candidate).reached_base
|
|
781
|
+
|
|
782
|
+
# -- push ----------------------------------------------------------------------------
|
|
783
|
+
def push(
|
|
784
|
+
self, *, sha: str, branch: str, expected_old: str | None, default_branch: str
|
|
785
|
+
) -> PushResult:
|
|
786
|
+
"""Compare-and-swap ``refs/heads/<branch>`` from ``expected_old`` to ``sha``.
|
|
787
|
+
|
|
788
|
+
Refused with :class:`GitTransportError` before any process when the
|
|
789
|
+
branch is the default branch (compared case-insensitively) or not a
|
|
790
|
+
plain branch name, or a SHA is not a full id. Then the candidate is
|
|
791
|
+
read as its own bytes and, when ``expected_old`` is given, proven to
|
|
792
|
+
descend from it (D6.2): the lease alone would permit a
|
|
793
|
+
non-fast-forward. ``expected_old=None`` means the branch must not
|
|
794
|
+
exist. A raised :class:`GitTransportError` therefore means no push
|
|
795
|
+
was attempted; once git runs, the outcome is returned, ambiguity
|
|
796
|
+
included (:attr:`PushOutcome.UNKNOWN`).
|
|
797
|
+
|
|
798
|
+
That the candidate descends from the recorded base and differs from
|
|
799
|
+
it is the caller's precondition (:meth:`prove_range`).
|
|
800
|
+
"""
|
|
801
|
+
_object_format(sha)
|
|
802
|
+
if expected_old is not None and _object_format(expected_old) != _object_format(sha):
|
|
803
|
+
raise GitTransportError(
|
|
804
|
+
f"the expected old value {expected_old!r} and the candidate {sha} use "
|
|
805
|
+
"different object formats"
|
|
806
|
+
)
|
|
807
|
+
if not default_branch:
|
|
808
|
+
raise GitTransportError("the default branch must be named to refuse a push to it")
|
|
809
|
+
if not valid_branch_name(branch):
|
|
810
|
+
raise GitTransportError(f"{branch!r} is not a plain branch name this transport pushes")
|
|
811
|
+
if branch.lower() == default_branch.lower():
|
|
812
|
+
raise GitTransportError(
|
|
813
|
+
f"refusing to push to {branch!r}: it is the default branch, which changes only "
|
|
814
|
+
"through a reviewed PR"
|
|
815
|
+
)
|
|
816
|
+
gh_path = self._gh_path()
|
|
817
|
+
self.read_commit(sha)
|
|
818
|
+
if expected_old is not None and not self.descends_from(sha, expected_old):
|
|
819
|
+
raise GitTransportError(
|
|
820
|
+
f"refusing to push {sha} to {branch!r}: it does not descend from the branch's "
|
|
821
|
+
f"expected head {expected_old}, so the update would not be a fast-forward"
|
|
822
|
+
)
|
|
823
|
+
ref = f"refs/heads/{branch}"
|
|
824
|
+
lease = f"--force-with-lease={ref}:{expected_old or ''}"
|
|
825
|
+
|
|
826
|
+
def result(outcome: PushOutcome, detail: str) -> PushResult:
|
|
827
|
+
return PushResult(
|
|
828
|
+
outcome=outcome,
|
|
829
|
+
branch=branch,
|
|
830
|
+
sha=sha,
|
|
831
|
+
expected_old=expected_old,
|
|
832
|
+
detail=redact(detail)[:_DETAIL_CHARS],
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
with self._git_dir(_object_format(sha)) as git_dir:
|
|
836
|
+
request = self._request(
|
|
837
|
+
git_dir,
|
|
838
|
+
[
|
|
839
|
+
"push",
|
|
840
|
+
"--porcelain",
|
|
841
|
+
"--no-verify",
|
|
842
|
+
"--no-follow-tags",
|
|
843
|
+
"--recurse-submodules=no",
|
|
844
|
+
lease,
|
|
845
|
+
self._remote.url,
|
|
846
|
+
f"{sha}:{ref}",
|
|
847
|
+
],
|
|
848
|
+
gh_path=gh_path,
|
|
849
|
+
)
|
|
850
|
+
try:
|
|
851
|
+
res = self._runner(request)
|
|
852
|
+
except ExecutionError as exc:
|
|
853
|
+
# It may have failed after git sent the update.
|
|
854
|
+
return result(PushOutcome.UNKNOWN, f"git push did not complete: {exc}")
|
|
855
|
+
if res.timed_out:
|
|
856
|
+
return result(
|
|
857
|
+
PushOutcome.UNKNOWN,
|
|
858
|
+
f"git push timed out after {request.timeout_seconds}s; whether the remote ref "
|
|
859
|
+
"moved is unknown",
|
|
860
|
+
)
|
|
861
|
+
if res.truncated:
|
|
862
|
+
return result(PushOutcome.UNKNOWN, "git push output was truncated")
|
|
863
|
+
outcome, detail = _classify_push(res, ref)
|
|
864
|
+
return result(outcome, detail)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _private_config(object_format: str) -> str:
|
|
868
|
+
"""The whole configuration of a private git directory."""
|
|
869
|
+
lines = [
|
|
870
|
+
"[core]",
|
|
871
|
+
f"\trepositoryformatversion = {1 if object_format == 'sha256' else 0}",
|
|
872
|
+
"\tbare = true",
|
|
873
|
+
"\tcommitGraph = false",
|
|
874
|
+
"\tlogAllRefUpdates = false",
|
|
875
|
+
"[gc]",
|
|
876
|
+
"\tauto = 0",
|
|
877
|
+
"[maintenance]",
|
|
878
|
+
"\tauto = false",
|
|
879
|
+
"[fetch]",
|
|
880
|
+
"\twriteCommitGraph = false",
|
|
881
|
+
]
|
|
882
|
+
if object_format == "sha256":
|
|
883
|
+
lines += ["[extensions]", "\tobjectFormat = sha256"]
|
|
884
|
+
return "\n".join(lines) + "\n"
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _hash_commit(raw: bytes, object_format: str) -> str:
|
|
888
|
+
"""The object id of a commit with content ``raw``."""
|
|
889
|
+
algorithm = hashlib.sha256 if object_format == "sha256" else hashlib.sha1
|
|
890
|
+
return algorithm(b"commit %d\0" % len(raw) + raw).hexdigest()
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _lossy(sha: str) -> str:
|
|
894
|
+
return (
|
|
895
|
+
f"commit {sha} is not valid UTF-8, so its bytes cannot be authenticated through the "
|
|
896
|
+
"executor's text capture; it is not used"
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _parse_commit(sha: str, raw: bytes) -> CommitRecord:
|
|
901
|
+
"""Parents and message from authenticated commit bytes, exactly as git reads them.
|
|
902
|
+
|
|
903
|
+
Git takes the parents only from the ``parent`` lines directly after the
|
|
904
|
+
``tree`` line; a ``parent`` header anywhere else is ignored by git and
|
|
905
|
+
GitHub, so it is refused here rather than believed (believing it could
|
|
906
|
+
prove an ancestry no one else sees).
|
|
907
|
+
"""
|
|
908
|
+
header, _, message = raw.partition(b"\n\n")
|
|
909
|
+
lines = header.split(b"\n")
|
|
910
|
+
width = len(sha)
|
|
911
|
+
if not lines[0].startswith(b"tree ") or len(lines[0]) != 5 + width:
|
|
912
|
+
raise GitTransportError(f"commit {sha} does not start with a tree line")
|
|
913
|
+
tree = lines[0][5:].decode("ascii", errors="replace")
|
|
914
|
+
try:
|
|
915
|
+
same_format = _object_format(tree) == _object_format(sha)
|
|
916
|
+
except GitTransportError:
|
|
917
|
+
same_format = False
|
|
918
|
+
if not same_format:
|
|
919
|
+
raise GitTransportError(f"commit {sha} names a malformed tree")
|
|
920
|
+
parents: list[str] = []
|
|
921
|
+
index = 1
|
|
922
|
+
while index < len(lines) and lines[index].startswith(b"parent "):
|
|
923
|
+
parent = lines[index][7:].decode("ascii", errors="replace")
|
|
924
|
+
if len(parent) != width or _object_format(parent) != _object_format(sha):
|
|
925
|
+
raise GitTransportError(f"commit {sha} names a malformed parent")
|
|
926
|
+
parents.append(parent)
|
|
927
|
+
index += 1
|
|
928
|
+
if any(line.startswith(b"parent ") for line in lines[index:]):
|
|
929
|
+
raise GitTransportError(f"commit {sha} has a parent header out of place; it is not used")
|
|
930
|
+
return CommitRecord(
|
|
931
|
+
sha=sha,
|
|
932
|
+
parents=tuple(parents),
|
|
933
|
+
message=message.decode("utf-8", errors="replace"),
|
|
934
|
+
tree=tree,
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
_PORCELAIN_REJECTED = "[rejected]"
|
|
939
|
+
_PORCELAIN_REMOTE_REJECTED = "[remote rejected]"
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _classify_push(res: ExecutionResult, ref: str) -> tuple[PushOutcome, str]:
|
|
943
|
+
"""Read git's ``--porcelain`` status line for ``ref``.
|
|
944
|
+
|
|
945
|
+
``<flag>\\t<from>:<to>\\t<summary>``: ``*`` new ref, `` `` fast-forward,
|
|
946
|
+
``+`` forced (cannot happen once the fast-forward is proven, and it would
|
|
947
|
+
still mean the ref moved), ``=`` up to date, ``!`` rejected. The status
|
|
948
|
+
words are not translated by git. A success needs exit 0 as well; any
|
|
949
|
+
other combination is :attr:`PushOutcome.UNKNOWN`.
|
|
950
|
+
"""
|
|
951
|
+
line = None
|
|
952
|
+
for candidate in res.stdout.splitlines():
|
|
953
|
+
fields = candidate.split("\t")
|
|
954
|
+
if len(fields) >= 3 and fields[1].rpartition(":")[2] == ref:
|
|
955
|
+
line = fields
|
|
956
|
+
break
|
|
957
|
+
if line is None:
|
|
958
|
+
return PushOutcome.UNKNOWN, (
|
|
959
|
+
f"git push reported no status for {ref} (exit {res.exit_code}): {_tail(res)}"
|
|
960
|
+
)
|
|
961
|
+
flag, summary = line[0], "\t".join(line[2:])
|
|
962
|
+
if flag in ("*", " ", "+") and res.exit_code == 0:
|
|
963
|
+
return PushOutcome.PUSHED, summary
|
|
964
|
+
if flag == "=" and res.exit_code == 0:
|
|
965
|
+
return PushOutcome.UP_TO_DATE, summary
|
|
966
|
+
if flag == "!" and summary.startswith(_PORCELAIN_REJECTED):
|
|
967
|
+
return PushOutcome.LEASE_REJECTED, summary
|
|
968
|
+
if flag == "!" and summary.startswith(_PORCELAIN_REMOTE_REJECTED):
|
|
969
|
+
return PushOutcome.REMOTE_REJECTED, summary
|
|
970
|
+
return PushOutcome.UNKNOWN, f"{summary} (exit {res.exit_code})"
|