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,722 @@
1
+ """Byte-exact materialization of a REMOTE candidate, with a hash-back proof (#171).
2
+
3
+ A candidate is a commit id. :func:`materialize` writes the tree that commit
4
+ names into a fresh private directory and proves, before handing the
5
+ directory out, that what is on disk hashes back to that tree (ADR 0006
6
+ §2.6). The merge gate's export (:func:`autoforge.premerge.export_commit_tree`)
7
+ uses it, and candidate validation (#172) will.
8
+
9
+ - **Source (D6.1).** The commit, and every blob its tree names, must be in
10
+ the store the controller's own git reads: the operator's shared object
11
+ store, never an issue's private object directory, whose objects reach the
12
+ shared store only through the strict import. A missing commit or blob is
13
+ :class:`~autoforge.errors.CandidateNotMaterializableError`. The tree id is
14
+ read from the commit itself.
15
+ - **Writer (D6.2).** ``git ls-tree -r -t -z --full-tree`` lists the tree;
16
+ the blobs are streamed with ``git cat-file --batch``. Nothing else of git
17
+ takes part: no index, no ``checkout-index``, no attribute, eol conversion,
18
+ ``ident``, filter driver, ``core.autocrlf``, ``info/attributes`` or
19
+ ``core.attributesFile``, so the bytes on disk are the committed bytes and
20
+ no program the operator configured runs on candidate content. Names stay
21
+ bytes; blob content reaches the disk as the executor reads it, through
22
+ :attr:`~autoforge.executor.ExecutionRequest.on_chunk`, so it is never
23
+ decoded, never cut at a capture bound and never held whole in memory, and
24
+ the git process stays the executor's (ADR 0002). Every entry is created
25
+ exclusively, relative to the descriptor of the directory created for its
26
+ parent tree, never through a link: a directory for a tree, a ``0644`` or
27
+ ``0755`` file, a symlink holding its link text, and an empty directory
28
+ for a gitlink (counted; the submodule commit is never read). There is no
29
+ ``.git``. A name that is ``.``, ``..`` or ``.git`` in any case, empty, or
30
+ not below a tree the listing holds (a name carrying ``/``) is refused
31
+ before anything is written.
32
+ - **Proof (D6.3).** The controller's own walk, with no git process: every
33
+ entry is ``lstat``-ed relative to its directory's descriptor, never
34
+ followed; blob ids are recomputed from the bytes on disk (a symlink's
35
+ from its link text), the mode from the file type and the owner's execute
36
+ bit, and tree ids bottom up in git's entry order, in the repository's
37
+ object format. A gitlink path must be an empty directory, and its entry
38
+ is the one the listing recorded. Every directory must hash to the tree
39
+ the listing names for it and the root to the commit's tree, with no entry
40
+ extra or missing. A mismatch is a
41
+ :class:`~autoforge.errors.MaterializationError` naming the entry.
42
+
43
+ Every git process is the hardened local request
44
+ (:func:`~autoforge.git_transport.local_git_request`, ADR 0004 D7.3, D7.5):
45
+ no replacement objects, no commit-graph, no hooks, an allow-listed
46
+ environment. The directory is removed when the context exits, whatever
47
+ happened; a killed process leaves only that directory, which is never read
48
+ again.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import hashlib
54
+ import os
55
+ import re
56
+ import shutil
57
+ import stat
58
+ import tempfile
59
+ import threading
60
+ from collections.abc import Callable, Iterator
61
+ from contextlib import contextmanager
62
+ from dataclasses import dataclass
63
+ from pathlib import Path
64
+
65
+ from .errors import CandidateNotMaterializableError, MaterializationError
66
+ from .executor import ExecutionRequest, ExecutionResult
67
+ from .git_transport import local_git_request
68
+ from .redaction import redact
69
+
70
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
71
+
72
+ # The method identifier a validation policy digest names (ADR 0006 D8.1).
73
+ METHOD = "byte-exact-v1"
74
+
75
+ # Listing and streaming a large tree are bounded by time, as the export was.
76
+ _GIT_TIMEOUT_SECONDS = 600
77
+ # What the executor keeps of a streamed process's output: the stream goes
78
+ # to disk through ``on_chunk``, so the capture serves only the error tail.
79
+ _STREAM_CAPTURE_BYTES = 64 * 1024
80
+ # A ``cat-file --batch`` header is ``<id> <type> <size>``.
81
+ _MAX_HEADER_BYTES = 256
82
+ # A symlink's target is held in memory until it is created; the kernel
83
+ # refuses one of PATH_MAX or more anyway.
84
+ _MAX_LINK_BYTES = 4096
85
+ _READ_CHUNK_BYTES = 1024 * 1024
86
+
87
+ _HEX_LENGTH = {"sha1": 40, "sha256": 64}
88
+ _HEX_RE = re.compile(rb"[0-9a-f]+")
89
+
90
+ # Modes as a tree object writes them, and the type ``ls-tree`` prints.
91
+ _FILE = b"100644"
92
+ _EXECUTABLE = b"100755"
93
+ _SYMLINK = b"120000"
94
+ _TREE = b"40000"
95
+ _GITLINK = b"160000"
96
+ _TYPES = {
97
+ _FILE: b"blob",
98
+ _EXECUTABLE: b"blob",
99
+ _SYMLINK: b"blob",
100
+ _TREE: b"tree",
101
+ _GITLINK: b"commit",
102
+ }
103
+
104
+ _DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
105
+ _NEW_FILE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
106
+ # Non-blocking, so a FIFO swapped in after the ``lstat`` cannot stall the proof.
107
+ _READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class MaterializedTree:
112
+ """A candidate written out under ``root`` and proven to be its tree.
113
+
114
+ ``files`` counts the blobs written (regular files and symlinks);
115
+ ``gitlinks`` the unpopulated submodule directories. ``root`` is removed
116
+ when the context that produced it exits.
117
+ """
118
+
119
+ commit: str
120
+ tree: str
121
+ object_format: str
122
+ root: Path
123
+ files: int
124
+ gitlinks: int
125
+ method: str = METHOD
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class _Entry:
130
+ """One ``ls-tree`` record: ``path`` is ``parent`` and ``name`` joined by ``/``."""
131
+
132
+ path: bytes
133
+ parent: bytes
134
+ name: bytes
135
+ mode: bytes
136
+ oid: str
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class _Listing:
141
+ """A tree's entries in ``ls-tree`` order: every tree before what it holds."""
142
+
143
+ tree: str
144
+ entries: tuple[_Entry, ...]
145
+
146
+
147
+ def _shown(path: bytes) -> str:
148
+ """``path`` for a message: escaped, bounded and redacted, never decoded raw."""
149
+ text = path.decode("utf-8", "backslashreplace")
150
+ text = "".join(c if c.isprintable() else f"\\x{ord(c):02x}" for c in text)
151
+ if len(text) > 200:
152
+ text = text[:200] + "..."
153
+ return redact(text) or "(root)"
154
+
155
+
156
+ def _request(repo: str, args: list[str]) -> ExecutionRequest:
157
+ return local_git_request(["-C", repo, *args], timeout_seconds=_GIT_TIMEOUT_SECONDS)
158
+
159
+
160
+ def _failure(res: ExecutionResult, args: list[str]) -> str:
161
+ """Why a git process did not answer, or "" when it did (exit 0, whole)."""
162
+ shown = " ".join(["git", *args])
163
+ if res.timed_out:
164
+ return f"`{shown}` timed out after {_GIT_TIMEOUT_SECONDS}s"
165
+ if res.exit_code != 0:
166
+ tail = redact((res.stderr or res.stdout or "").strip()[-500:])
167
+ return f"`{shown}` failed (exit {res.exit_code}): {tail}"
168
+ if res.capture_abandoned:
169
+ return f"`{shown}` left its output open past its exit"
170
+ return ""
171
+
172
+
173
+ def _git(runner: Runner, repo: str, args: list[str], stdin: bytes | None = None) -> str:
174
+ """One git plumbing command whose whole, short output is the answer."""
175
+ req = _request(repo, args)
176
+ req.stdin_data = stdin
177
+ res = runner(req)
178
+ problem = _failure(res, args)
179
+ if not problem and res.stdout_truncated:
180
+ problem = f"`git {' '.join(args)}` output was truncated at the executor's capture bound"
181
+ if problem:
182
+ raise MaterializationError(problem)
183
+ return res.stdout or ""
184
+
185
+
186
+ def read_object_format(runner: Runner, repo: str) -> str:
187
+ """The object format of ``repo``'s store: ``sha1`` or ``sha256``."""
188
+ found = _git(runner, repo, ["rev-parse", "--show-object-format"]).strip()
189
+ if found not in _HEX_LENGTH:
190
+ raise MaterializationError(f"{repo} uses the object format {found!r}, which is not known")
191
+ return found
192
+
193
+
194
+ def _is_oid(value: bytes, object_format: str) -> bool:
195
+ return len(value) == _HEX_LENGTH[object_format] and _HEX_RE.fullmatch(value) is not None
196
+
197
+
198
+ def _commit_tree(runner: Runner, repo: str, commit: str, object_format: str) -> str:
199
+ """The tree ``commit`` names, after it is found to be a commit in the store."""
200
+ if object_format not in _HEX_LENGTH:
201
+ raise MaterializationError(f"unknown object format {object_format!r}")
202
+ if not _is_oid(commit.encode("ascii", "replace"), object_format):
203
+ raise CandidateNotMaterializableError(
204
+ f"{commit!r} is not a full {object_format} commit id of {repo}"
205
+ )
206
+ answer = _git(runner, repo, ["cat-file", "--batch-check"], f"{commit}\n".encode()).split()
207
+ if answer == [commit, "missing"]:
208
+ raise CandidateNotMaterializableError(
209
+ f"commit {commit} is not in the shared object store of {repo}"
210
+ )
211
+ if len(answer) != 3 or answer[0] != commit or answer[1] != "commit":
212
+ kind = answer[1] if len(answer) == 3 and answer[0] == commit else "unreadable"
213
+ raise CandidateNotMaterializableError(
214
+ f"{commit} in {repo} is a {kind} object, not a commit"
215
+ )
216
+ tree = _git(runner, repo, ["rev-parse", "--verify", "--quiet", f"{commit}^{{tree}}"]).strip()
217
+ if not _is_oid(tree.encode("ascii", "replace"), object_format):
218
+ raise MaterializationError(f"commit {commit} names no readable tree: {tree[:100]!r}")
219
+ return tree
220
+
221
+
222
+ class _Collector:
223
+ """An ``on_chunk`` hook keeping a process's stdout bytes as they were written."""
224
+
225
+ def __init__(self) -> None:
226
+ self.data = bytearray()
227
+
228
+ def __call__(self, stream: str, chunk: bytes) -> None:
229
+ if stream == "stdout":
230
+ self.data += chunk
231
+
232
+
233
+ def _check_name(name: bytes, path: bytes, commit: str) -> None:
234
+ """Refuse a name no tree entry may carry into the export (D6.2)."""
235
+ problem = ""
236
+ if not name:
237
+ problem = "an empty name"
238
+ elif name in (b".", b".."):
239
+ problem = f"the name {name.decode()!r}"
240
+ elif name.lower() == b".git":
241
+ problem = "a .git entry"
242
+ elif b"\0" in name:
243
+ problem = "a NUL in its name"
244
+ if problem:
245
+ raise MaterializationError(
246
+ f"commit {commit}: the entry {_shown(path)} is refused: {problem}"
247
+ )
248
+
249
+
250
+ def _list_tree(runner: Runner, repo: str, commit: str, tree: str, object_format: str) -> _Listing:
251
+ """Every entry of ``tree``, validated before anything is written."""
252
+ args = ["ls-tree", "-r", "-t", "-z", "--full-tree", tree]
253
+ collector = _Collector()
254
+ req = _request(repo, args)
255
+ req.on_chunk = collector
256
+ req.max_output_bytes = _STREAM_CAPTURE_BYTES
257
+ problem = _failure(runner(req), args)
258
+ if problem:
259
+ raise MaterializationError(f"commit {commit}: listing its tree failed: {problem}")
260
+ raw = bytes(collector.data)
261
+ if raw and not raw.endswith(b"\0"):
262
+ raise MaterializationError(f"commit {commit}: the listing of {tree} ends mid-record")
263
+ trees: set[bytes] = {b""}
264
+ entries: list[_Entry] = []
265
+ seen: set[bytes] = set()
266
+ for record in raw.split(b"\0")[:-1]:
267
+ meta, tab, path = record.partition(b"\t")
268
+ fields = meta.split(b" ")
269
+ if not tab or len(fields) != 3:
270
+ raise MaterializationError(
271
+ f"commit {commit}: unreadable listing record {_shown(record)}"
272
+ )
273
+ mode, kind, oid = fields
274
+ if mode == b"040000":
275
+ mode = _TREE
276
+ if _TYPES.get(mode) != kind or not _is_oid(oid, object_format):
277
+ raise MaterializationError(
278
+ f"commit {commit}: the entry {_shown(path)} has the unsupported mode and type "
279
+ f"{_shown(mode)} {_shown(kind)}"
280
+ )
281
+ parent, slash, name = path.rpartition(b"/")
282
+ _check_name(name, path, commit)
283
+ if parent not in trees or (slash and not parent):
284
+ # ``ls-tree`` joins names with ``/``: a path whose parent was not
285
+ # listed as a tree, or that starts with ``/``, came from a name
286
+ # holding ``/`` (a tree below a gitlink is never listed).
287
+ raise MaterializationError(
288
+ f"commit {commit}: the entry {_shown(path)} is refused: it is not below a tree "
289
+ "of the listing (a name holding '/')"
290
+ )
291
+ if path in seen:
292
+ raise MaterializationError(f"commit {commit}: the entry {_shown(path)} is listed twice")
293
+ seen.add(path)
294
+ if mode == _TREE:
295
+ trees.add(path)
296
+ entries.append(_Entry(path, parent, name, mode, oid.decode("ascii")))
297
+ return _Listing(tree=tree, entries=tuple(entries))
298
+
299
+
300
+ class _Writer:
301
+ """Creates a listing's entries below ``root``, in listing order.
302
+
303
+ Trees and gitlinks are created as they are reached; a blob is created
304
+ when its content arrives (:meth:`open_blob`, :meth:`write`,
305
+ :meth:`close_blob`). The descriptors of the directories on the current
306
+ path are held open, so every entry is created relative to the one made
307
+ for its parent, never through a path a link could redirect.
308
+ """
309
+
310
+ def __init__(self, root: Path, listing: _Listing, commit: str) -> None:
311
+ self._entries = listing.entries
312
+ self._commit = commit
313
+ self._next = 0
314
+ self._stack: list[tuple[bytes, int]] = [(b"", os.open(root, _DIR_FLAGS))]
315
+ self._blob: _Entry | None = None
316
+ self._fd: int | None = None
317
+ self._link: bytearray | None = None
318
+
319
+ def _fail(self, entry: _Entry, what: str, exc: OSError | None = None) -> MaterializationError:
320
+ reason = f": {exc.strerror or exc}" if exc is not None else ""
321
+ return MaterializationError(
322
+ f"commit {self._commit}: {what} {_shown(entry.path)} failed{reason}"
323
+ )
324
+
325
+ def _parent_fd(self, entry: _Entry) -> int:
326
+ while self._stack and self._stack[-1][0] != entry.parent:
327
+ os.close(self._stack.pop()[1])
328
+ if not self._stack:
329
+ raise MaterializationError(
330
+ f"commit {self._commit}: the entry {_shown(entry.path)} is listed after its "
331
+ "parent tree was complete"
332
+ )
333
+ return self._stack[-1][1]
334
+
335
+ def _directory(self, entry: _Entry) -> None:
336
+ parent = self._parent_fd(entry)
337
+ try:
338
+ os.mkdir(entry.name, 0o755, dir_fd=parent)
339
+ fd = os.open(entry.name, _DIR_FLAGS, dir_fd=parent)
340
+ except OSError as exc:
341
+ raise self._fail(entry, "creating the directory", exc) from exc
342
+ try:
343
+ os.fchmod(fd, 0o755)
344
+ except OSError as exc:
345
+ os.close(fd)
346
+ raise self._fail(entry, "creating the directory", exc) from exc
347
+ if entry.mode == _TREE:
348
+ self._stack.append((entry.path, fd))
349
+ else:
350
+ os.close(fd)
351
+
352
+ def next_blob(self) -> _Entry | None:
353
+ """Create every tree and gitlink up to the next blob, and return that blob."""
354
+ while self._next < len(self._entries):
355
+ entry = self._entries[self._next]
356
+ self._next += 1
357
+ if entry.mode in (_TREE, _GITLINK):
358
+ self._directory(entry)
359
+ else:
360
+ return entry
361
+ return None
362
+
363
+ def open_blob(self, entry: _Entry) -> None:
364
+ parent = self._parent_fd(entry)
365
+ self._blob = entry
366
+ if entry.mode == _SYMLINK:
367
+ self._link = bytearray()
368
+ return
369
+ try:
370
+ self._fd = os.open(entry.name, _NEW_FILE_FLAGS, 0o600, dir_fd=parent)
371
+ except OSError as exc:
372
+ raise self._fail(entry, "creating the file", exc) from exc
373
+
374
+ def write(self, data: memoryview) -> None:
375
+ assert self._blob is not None
376
+ if self._link is not None:
377
+ self._link += data
378
+ if len(self._link) > _MAX_LINK_BYTES:
379
+ raise MaterializationError(
380
+ f"commit {self._commit}: the symlink {_shown(self._blob.path)} holds more "
381
+ f"than {_MAX_LINK_BYTES} bytes of link text"
382
+ )
383
+ return
384
+ assert self._fd is not None
385
+ try:
386
+ while data:
387
+ written = os.write(self._fd, data)
388
+ data = data[written:]
389
+ except OSError as exc:
390
+ raise self._fail(self._blob, "writing", exc) from exc
391
+
392
+ def close_blob(self) -> None:
393
+ entry = self._blob
394
+ assert entry is not None
395
+ try:
396
+ if self._link is not None:
397
+ os.symlink(bytes(self._link), entry.name, dir_fd=self._parent_fd(entry))
398
+ else:
399
+ assert self._fd is not None
400
+ os.fchmod(self._fd, 0o755 if entry.mode == _EXECUTABLE else 0o644)
401
+ except OSError as exc:
402
+ raise self._fail(entry, "creating", exc) from exc
403
+ finally:
404
+ self._close_file()
405
+ self._link = None
406
+ self._blob = None
407
+
408
+ def _close_file(self) -> None:
409
+ if self._fd is not None:
410
+ fd, self._fd = self._fd, None
411
+ os.close(fd)
412
+
413
+ def finish(self) -> None:
414
+ """Create what follows the last blob; every entry must have been reached."""
415
+ if self.next_blob() is not None:
416
+ raise MaterializationError(
417
+ f"commit {self._commit}: git sent fewer blobs than the tree lists"
418
+ )
419
+
420
+ def close(self) -> None:
421
+ self._close_file()
422
+ while self._stack:
423
+ os.close(self._stack.pop()[1])
424
+
425
+
426
+ class _BlobStream:
427
+ """An ``on_chunk`` hook parsing ``git cat-file --batch`` into the writer.
428
+
429
+ Each blob arrives as ``<id> blob <size>\\n<content>\\n``, in the order
430
+ the ids were asked for. The first problem is kept and ``stop`` is set,
431
+ so the executor ends the process; nothing is read after it, or after
432
+ :meth:`close`.
433
+ """
434
+
435
+ def __init__(self, writer: _Writer, commit: str) -> None:
436
+ self._writer = writer
437
+ self._commit = commit
438
+ self._header = bytearray()
439
+ self._blob: _Entry | None = None
440
+ self._remaining = 0
441
+ self._trailer = False
442
+ self._lock = threading.Lock()
443
+ self._closed = False
444
+ self.error: Exception | None = None
445
+ self.stop = threading.Event()
446
+ self.done = False
447
+ self._expect()
448
+
449
+ def _expect(self) -> None:
450
+ self._blob = self._writer.next_blob()
451
+ self.done = self._blob is None
452
+
453
+ def __call__(self, stream: str, chunk: bytes) -> None:
454
+ if stream != "stdout":
455
+ return
456
+ with self._lock:
457
+ if self._closed or self.error is not None:
458
+ return
459
+ try:
460
+ self._feed(memoryview(chunk))
461
+ except (MaterializationError, CandidateNotMaterializableError, OSError) as exc:
462
+ self.error = exc
463
+ self.stop.set()
464
+
465
+ def _feed(self, chunk: memoryview) -> None:
466
+ while chunk:
467
+ if self.done:
468
+ raise MaterializationError(
469
+ f"commit {self._commit}: git sent more blobs than the tree lists"
470
+ )
471
+ if self._trailer:
472
+ if chunk[0] != 0x0A:
473
+ raise MaterializationError(
474
+ f"commit {self._commit}: unexpected git output after a blob's content"
475
+ )
476
+ chunk = chunk[1:]
477
+ self._trailer = False
478
+ self._writer.close_blob()
479
+ self._expect()
480
+ elif self._remaining:
481
+ part = chunk[: self._remaining]
482
+ self._writer.write(part)
483
+ self._remaining -= len(part)
484
+ chunk = chunk[len(part) :]
485
+ self._trailer = not self._remaining
486
+ else:
487
+ room = _MAX_HEADER_BYTES - len(self._header)
488
+ end = bytes(chunk[: room + 1]).find(b"\n")
489
+ if end < 0:
490
+ if len(chunk) > room:
491
+ raise MaterializationError(
492
+ f"commit {self._commit}: an unreadable `git cat-file` header"
493
+ )
494
+ self._header += chunk
495
+ return
496
+ self._header += chunk[:end]
497
+ chunk = chunk[end + 1 :]
498
+ header, self._header = bytes(self._header), bytearray()
499
+ self._start(header)
500
+
501
+ def _start(self, header: bytes) -> None:
502
+ blob = self._blob
503
+ assert blob is not None
504
+ oid = blob.oid.encode("ascii")
505
+ fields = header.split(b" ")
506
+ if fields == [oid, b"missing"]:
507
+ raise CandidateNotMaterializableError(
508
+ f"commit {self._commit}: the blob {blob.oid} of {_shown(blob.path)} is not in "
509
+ "the shared object store"
510
+ )
511
+ if len(fields) != 3 or fields[0] != oid or fields[1] != b"blob" or not fields[2].isdigit():
512
+ raise MaterializationError(
513
+ f"commit {self._commit}: git answered {_shown(header)} for the blob of "
514
+ f"{_shown(blob.path)}"
515
+ )
516
+ self._writer.open_blob(blob)
517
+ self._remaining = int(fields[2])
518
+ self._trailer = not self._remaining
519
+
520
+ def close(self) -> None:
521
+ with self._lock:
522
+ self._closed = True
523
+
524
+
525
+ def _write(runner: Runner, repo: str, commit: str, listing: _Listing, root: Path) -> None:
526
+ """Write ``listing`` below ``root``, the blobs streamed from the store."""
527
+ writer = _Writer(root, listing, commit)
528
+ try:
529
+ stream = _BlobStream(writer, commit)
530
+ blobs = [e.oid for e in listing.entries if e.mode not in (_TREE, _GITLINK)]
531
+ if blobs:
532
+ args = ["cat-file", "--batch", "--buffer"]
533
+ req = _request(repo, args)
534
+ req.stdin_data = "".join(f"{oid}\n" for oid in blobs).encode("ascii")
535
+ req.on_chunk = stream
536
+ req.stop = stream.stop
537
+ req.max_output_bytes = _STREAM_CAPTURE_BYTES
538
+ try:
539
+ res = runner(req)
540
+ finally:
541
+ stream.close()
542
+ if stream.error is not None:
543
+ raise stream.error
544
+ problem = _failure(res, args)
545
+ if problem:
546
+ raise MaterializationError(
547
+ f"commit {commit}: streaming its blobs failed: {problem}"
548
+ )
549
+ if not stream.done:
550
+ raise MaterializationError(
551
+ f"commit {commit}: git sent fewer blobs than the tree lists"
552
+ )
553
+ writer.finish()
554
+ finally:
555
+ writer.close()
556
+
557
+
558
+ def _blob_id(algorithm: str, content: bytes) -> str:
559
+ return hashlib.new(algorithm, b"blob %d\0" % len(content) + content).hexdigest()
560
+
561
+
562
+ def _file_id(algorithm: str, dir_fd: int, name: bytes, seen: os.stat_result) -> tuple[bytes, str]:
563
+ """The mode and blob id of the regular file ``name``, from its bytes on disk."""
564
+ fd = os.open(name, _READ_FLAGS, dir_fd=dir_fd)
565
+ try:
566
+ st = os.fstat(fd)
567
+ if not stat.S_ISREG(st.st_mode) or (st.st_dev, st.st_ino) != (seen.st_dev, seen.st_ino):
568
+ raise OSError(0, "replaced while it was proved")
569
+ digest = hashlib.new(algorithm, b"blob %d\0" % st.st_size)
570
+ total = 0
571
+ while chunk := os.read(fd, _READ_CHUNK_BYTES):
572
+ digest.update(chunk)
573
+ total += len(chunk)
574
+ if total != st.st_size:
575
+ raise OSError(0, "changed size while it was proved")
576
+ finally:
577
+ os.close(fd)
578
+ return (_EXECUTABLE if st.st_mode & stat.S_IXUSR else _FILE), digest.hexdigest()
579
+
580
+
581
+ def _tree_id(algorithm: str, entries: list[tuple[bytes, bytes, str]]) -> str:
582
+ """The id of a tree holding ``entries`` (name, mode, id), in git's order.
583
+
584
+ A tree entry sorts as its name followed by ``/``; every other entry,
585
+ a gitlink included, as its name.
586
+ """
587
+ entries.sort(key=lambda e: e[0] + b"/" if e[1] == _TREE else e[0])
588
+ body = b"".join(mode + b" " + name + b"\0" + bytes.fromhex(oid) for name, mode, oid in entries)
589
+ return hashlib.new(algorithm, b"tree %d\0" % len(body) + body).hexdigest()
590
+
591
+
592
+ @dataclass
593
+ class _Frame:
594
+ """One directory of the proof's walk: its names still to visit, its entries so far."""
595
+
596
+ path: bytes
597
+ fd: int
598
+ pending: list[bytes]
599
+ entries: list[tuple[bytes, bytes, str]]
600
+
601
+
602
+ def _join(parent: bytes, name: bytes) -> bytes:
603
+ return parent + b"/" + name if parent else name
604
+
605
+
606
+ def _prove(root: Path, listing: _Listing, commit: str, object_format: str) -> None:
607
+ """Prove that ``root`` holds exactly ``listing``'s tree (D6.3), or raise."""
608
+ expected = {listed.path: listed for listed in listing.entries}
609
+ children: dict[bytes, set[bytes]] = {b"": set()}
610
+ for listed in listing.entries:
611
+ children.setdefault(listed.parent, set()).add(listed.name)
612
+ if listed.mode == _TREE:
613
+ children.setdefault(listed.path, set())
614
+
615
+ def fail(path: bytes, what: str) -> MaterializationError:
616
+ return MaterializationError(
617
+ f"commit {commit}: the export does not hash back to its tree {listing.tree}: "
618
+ f"{_shown(path)} {what}"
619
+ )
620
+
621
+ frames: list[_Frame] = []
622
+
623
+ def enter(path: bytes, fd: int) -> None:
624
+ # Held by a frame at once, so the ``finally`` below closes it.
625
+ frames.append(_Frame(path, fd, [], []))
626
+ names = [os.fsencode(name) for name in os.listdir(fd)]
627
+ missing = sorted(children.get(path, set()) - set(names))
628
+ if missing:
629
+ raise fail(_join(path, missing[0]), "is missing")
630
+ frames[-1].pending = sorted(names, reverse=True)
631
+
632
+ path = b""
633
+ try:
634
+ enter(b"", os.open(root, _DIR_FLAGS))
635
+ while frames:
636
+ top = frames[-1]
637
+ if not top.pending:
638
+ path = top.path
639
+ oid = _tree_id(object_format, top.entries)
640
+ want = listing.tree if not top.path else expected[top.path].oid
641
+ if oid != want:
642
+ raise fail(top.path, f"hashes to the tree {oid}, the commit holds {want}")
643
+ frames.pop()
644
+ os.close(top.fd)
645
+ if frames:
646
+ frames[-1].entries.append((top.path.rpartition(b"/")[2], _TREE, oid))
647
+ continue
648
+ name = top.pending.pop()
649
+ path = _join(top.path, name)
650
+ entry = expected.get(path)
651
+ if entry is None or entry.parent != top.path:
652
+ raise fail(path, "is not in the commit")
653
+ st = os.lstat(name, dir_fd=top.fd)
654
+ if entry.mode == _GITLINK:
655
+ if not stat.S_ISDIR(st.st_mode):
656
+ raise fail(path, "is a gitlink, but not a directory")
657
+ fd = os.open(name, _DIR_FLAGS, dir_fd=top.fd)
658
+ try:
659
+ if os.listdir(fd):
660
+ raise fail(path, "is a gitlink, but its directory is not empty")
661
+ finally:
662
+ os.close(fd)
663
+ top.entries.append((name, _GITLINK, entry.oid))
664
+ continue
665
+ if stat.S_ISDIR(st.st_mode):
666
+ if entry.mode != _TREE:
667
+ raise fail(path, f"is a directory, the commit holds mode {entry.mode.decode()}")
668
+ enter(path, os.open(name, _DIR_FLAGS, dir_fd=top.fd))
669
+ continue
670
+ if stat.S_ISREG(st.st_mode):
671
+ mode, oid = _file_id(object_format, top.fd, name, st)
672
+ elif stat.S_ISLNK(st.st_mode):
673
+ mode, oid = _SYMLINK, _blob_id(object_format, os.readlink(name, dir_fd=top.fd))
674
+ else:
675
+ raise fail(path, "is neither a regular file, a symlink nor a directory")
676
+ if (mode, oid) != (entry.mode, entry.oid):
677
+ raise fail(
678
+ path,
679
+ f"is {mode.decode()} {oid} on disk, the commit holds "
680
+ f"{entry.mode.decode()} {entry.oid}",
681
+ )
682
+ top.entries.append((name, mode, oid))
683
+ except OSError as exc:
684
+ raise fail(path, f"could not be proved: {exc.strerror or exc}") from exc
685
+ finally:
686
+ for open_frame in frames:
687
+ os.close(open_frame.fd)
688
+
689
+
690
+ @contextmanager
691
+ def materialize(
692
+ runner: Runner, repo: str, commit: str, object_format: str, *, prefix: str
693
+ ) -> Iterator[MaterializedTree]:
694
+ """Write ``commit``'s tree into a fresh private directory, prove it, then remove it.
695
+
696
+ The directory is created by :func:`tempfile.mkdtemp` (mode ``0700``) in
697
+ the system temporary directory, its name starting with ``prefix``; it is
698
+ yielded only once proven, and removed when the context exits, on an
699
+ exception too.
700
+
701
+ Raises :class:`~autoforge.errors.CandidateNotMaterializableError` when
702
+ the commit or a blob it names is not in ``repo``'s object store, and
703
+ :class:`~autoforge.errors.MaterializationError` when the tree cannot be
704
+ listed, written or proven.
705
+ """
706
+ root = Path(tempfile.mkdtemp(prefix=prefix))
707
+ try:
708
+ tree = _commit_tree(runner, repo, commit, object_format)
709
+ listing = _list_tree(runner, repo, commit, tree, object_format)
710
+ _write(runner, repo, commit, listing, root)
711
+ _prove(root, listing, commit, object_format)
712
+ modes = [entry.mode for entry in listing.entries]
713
+ yield MaterializedTree(
714
+ commit=commit,
715
+ tree=tree,
716
+ object_format=object_format,
717
+ root=root,
718
+ files=sum(mode not in (_TREE, _GITLINK) for mode in modes),
719
+ gitlinks=modes.count(_GITLINK),
720
+ )
721
+ finally:
722
+ shutil.rmtree(root, ignore_errors=True)