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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. 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})"