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
autoforge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """AutoForge controller package."""
2
+
3
+ __version__ = "0.1.0a0"
4
+ __protocol_version__ = "12"
5
+ __prompt_version__ = "v12"
@@ -0,0 +1,653 @@
1
+ """The agent worktree's git surface, as the controller reads it (ADR 0005 D6.3-D6.5).
2
+
3
+ Mode-independent: the redirect these close needs no sandbox, only a
4
+ writable ``.git`` file, which every agent has. The engine calls these for a
5
+ REMOTE run's issue worktree, whatever ``sandbox.mode`` says (#168).
6
+
7
+ - **Binding (D6.4).** :func:`bound_admin_dir` finds the worktree's admin
8
+ dir through the repository's registry: exactly one
9
+ ``<common>/worktrees/<id>/gitdir`` names ``<worktree>/.git``, and the
10
+ worktree's ``.git`` is a regular file naming that admin dir, absolute or
11
+ relative (``worktree.useRelativePaths``). It replaces asking ``git -C
12
+ <worktree>`` where its repository is, which follows the agent-writable
13
+ ``.git`` file. :func:`registered_worktree_roots` is the registry's list of
14
+ working-tree roots, what ``git worktree list`` reports, read from the same
15
+ entries without git and without following a link: ``git worktree list``
16
+ resolves every listed worktree's ``HEAD``, through a link the agent
17
+ planted in its admin dir (#223). Both read the registry through a
18
+ descriptor opened ``O_NOFOLLOW`` (:func:`_registry_entries`): a registry
19
+ that is itself a link is refused, never listed.
20
+ - **Candidate (D6.3).** :func:`read_candidate` reads the worktree's
21
+ ``HEAD`` as ``worktrees/<id>/HEAD`` with ``--git-dir=<git common dir>``,
22
+ after ``lstat`` found a regular file there, reached from the common dir
23
+ through real directories (:func:`head_problem`): never
24
+ through the worktree's ``.git`` file, the admin dir's ``commondir`` or
25
+ ``gitdir``, or ``git -C <worktree>``. An attached ``HEAD`` is told by
26
+ ``symbolic-ref --no-recurse``, which never opens the reference it names
27
+ (#223).
28
+ - **Strict import (D6.5).** :func:`import_private_objects` copies the
29
+ objects reachable from that ``HEAD`` which the shared store lacks out of
30
+ the issue's private object directory (an enforced launch's overlay upper
31
+ layer), through ``index-pack --strict``, which writes each object under
32
+ the id it computes from its bytes. It reads the commit ``HEAD`` holds
33
+ itself, after the candidate read's ``lstat``: a link there fails the
34
+ import before anything is read through it, and an attached ``HEAD``
35
+ imports nothing. :func:`private_objects_problem` walks the directory
36
+ first without following a link: a root that is not a real directory
37
+ reached from the common dir through real directories, an alternates
38
+ file, or an entry that is neither a directory, a regular file nor an
39
+ overlay whiteout, is tampering
40
+ (:class:`~autoforge.errors.SandboxTamperError`). Its ``index-pack`` is
41
+ the one step that writes the shared store, so it alone runs under the
42
+ shared-Git lock (L1, ADR 0008 D12.1) and with automatic maintenance off
43
+ (D12.3).
44
+
45
+ Every git process is the controller's own hardened local request
46
+ (:func:`~autoforge.git_transport.local_git_request`, ADR 0004 D7.3, D7.5);
47
+ the private directory is named through ``GIT_ALTERNATE_OBJECT_DIRECTORIES``
48
+ for the processes that read it, as one double-quoted entry
49
+ (:func:`alternate_entry`) so that a ``:`` in its path does not split it,
50
+ and never added to the shared store's alternates.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import os
56
+ import re
57
+ import shutil
58
+ import stat
59
+ import tempfile
60
+ from collections.abc import Callable
61
+ from contextlib import AbstractContextManager
62
+ from dataclasses import dataclass
63
+ from pathlib import Path
64
+
65
+ from .errors import GitLockTimeout, LockError, SandboxTamperError, VerificationError
66
+ from .executor import ExecutionRequest, ExecutionResult, execute
67
+ from .git_transport import NO_AUTO_MAINTENANCE, local_git_request
68
+ from .locking import SharedGitLock
69
+ from .redaction import redact
70
+
71
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
72
+
73
+ GIT_TIMEOUT_SECONDS = 120
74
+ # An import moves at most one issue's new objects; a large one is a pack.
75
+ IMPORT_TIMEOUT_SECONDS = 600
76
+ # A pointer file holds one path.
77
+ _MAX_POINTER_BYTES = 4096
78
+ # A registry directory: a link there fails the open instead of being followed.
79
+ _DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
80
+ # The registry entries and private-directory entries a walk visits before
81
+ # it fails closed: a store bigger than this is not one issue's work.
82
+ MAX_REGISTRY_ENTRIES = 10_000
83
+ MAX_PRIVATE_ENTRIES = 1_000_000
84
+ _SHA_RE = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
85
+ # The files that make git read objects from somewhere else (D6.5).
86
+ ALTERNATES_FILES = ("info/alternates", "info/http-alternates")
87
+
88
+
89
+ def _run(runner: Runner | None, request: ExecutionRequest) -> ExecutionResult:
90
+ return (runner or execute)(request)
91
+
92
+
93
+ def _detail(res: ExecutionResult) -> str:
94
+ lines = (res.stderr or res.stdout or "").strip().splitlines()
95
+ return f": {redact(lines[-1])[:200]}" if lines else ""
96
+
97
+
98
+ def _ended(res: ExecutionResult) -> str:
99
+ return "timed out" if res.timed_out else f"exited {res.exit_code}"
100
+
101
+
102
+ def _read_pointer(path: Path | str, dir_fd: int | None = None) -> str | None:
103
+ """A pointer file's single line, or ``None`` unless it is a regular file.
104
+
105
+ Opened without following a link and without blocking, and checked again
106
+ on the descriptor: a file swapped for a FIFO after the ``lstat`` neither
107
+ blocks the read nor is read. ``dir_fd``: ``path`` is a name in that
108
+ directory, reached through no path at all.
109
+ """
110
+ try:
111
+ if not stat.S_ISREG(os.lstat(path, dir_fd=dir_fd).st_mode):
112
+ return None
113
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=dir_fd)
114
+ except OSError:
115
+ return None
116
+ with os.fdopen(fd, "rb") as handle:
117
+ if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
118
+ return None
119
+ data = handle.read(_MAX_POINTER_BYTES + 1)
120
+ if len(data) > _MAX_POINTER_BYTES or b"\0" in data:
121
+ return None
122
+ text = data.decode("utf-8", "replace").strip()
123
+ return text if text and "\n" not in text else None
124
+
125
+
126
+ def _names(value: str, base: Path, expected: Path) -> bool:
127
+ """Whether the pointer ``value`` (absolute, or relative to ``base``) names ``expected``."""
128
+ named = os.path.normpath(value if os.path.isabs(value) else os.path.join(base, value))
129
+ return named in (str(expected), os.path.realpath(expected))
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class _RegistryEntry:
134
+ """A registry entry ``<common>/worktrees/<id>`` with a regular ``gitdir`` file."""
135
+
136
+ admin: Path
137
+ gitdir: str
138
+ locked: bool
139
+
140
+
141
+ def _registry_entries(
142
+ registry: Path, refuse: Callable[[str], VerificationError]
143
+ ) -> list[_RegistryEntry] | None:
144
+ """The registry's entries, read without following a link (D6.4, #223).
145
+
146
+ ``None`` when ``registry`` is absent. The registry itself is ``lstat``-ed
147
+ and opened ``O_NOFOLLOW``, and each entry and its files are reached from
148
+ that descriptor, never by path: a registry that is a link (an agent
149
+ without an enforced sandbox can replace it) is refused before the
150
+ directory it names is opened, and so is one that is not a directory,
151
+ cannot be read, or holds more than :data:`MAX_REGISTRY_ENTRIES`
152
+ entries. An entry that is not a real directory, or whose ``gitdir`` is
153
+ not a regular file (:func:`_read_pointer`), is left out.
154
+ """
155
+ try:
156
+ mode = os.lstat(registry).st_mode
157
+ except FileNotFoundError:
158
+ return None
159
+ except OSError as exc:
160
+ raise refuse(f"{registry} cannot be checked ({exc.strerror})") from None
161
+ if stat.S_ISLNK(mode):
162
+ raise refuse(f"{registry} is a link: the controller lists no registry through one")
163
+ if not stat.S_ISDIR(mode):
164
+ raise refuse(f"{registry} is not a directory")
165
+ found: list[_RegistryEntry] = []
166
+ try:
167
+ # A link swapped in since the lstat fails the open.
168
+ fd = os.open(registry, _DIR_FLAGS)
169
+ except OSError as exc:
170
+ raise refuse(f"{registry} cannot be read ({exc.strerror})") from None
171
+ try:
172
+ with os.scandir(fd) as entries:
173
+ for count, entry in enumerate(entries):
174
+ if count >= MAX_REGISTRY_ENTRIES:
175
+ raise refuse(f"{registry} holds more than {MAX_REGISTRY_ENTRIES} entries")
176
+ if not entry.is_dir(follow_symlinks=False):
177
+ continue
178
+ try:
179
+ admin_fd = os.open(entry.name, _DIR_FLAGS, dir_fd=fd)
180
+ except OSError:
181
+ continue
182
+ try:
183
+ value = _read_pointer("gitdir", admin_fd)
184
+ if value is None:
185
+ continue
186
+ try:
187
+ os.lstat("locked", dir_fd=admin_fd)
188
+ locked = True
189
+ except OSError:
190
+ locked = False
191
+ finally:
192
+ os.close(admin_fd)
193
+ found.append(_RegistryEntry(registry / entry.name, value, locked))
194
+ except OSError as exc:
195
+ raise refuse(f"{registry} cannot be read ({exc.strerror})") from None
196
+ finally:
197
+ os.close(fd)
198
+ return found
199
+
200
+
201
+ def bound_admin_dir(common: Path, worktree: Path) -> Path:
202
+ """The admin dir ``<common>/worktrees/<id>`` the registry binds ``worktree`` to (D6.4).
203
+
204
+ ``worktree`` is the literal path the controller derived (its link and
205
+ location checks are the caller's). Refused, with a
206
+ :class:`VerificationError` naming the inconsistency and ``git worktree
207
+ repair``, unless exactly one registry entry's ``gitdir`` names
208
+ ``<worktree>/.git`` and the worktree's ``.git`` is a regular file
209
+ naming that entry. The registry is read without following a link
210
+ (:func:`_registry_entries`).
211
+ """
212
+ dotgit = worktree / ".git"
213
+ registry = common / "worktrees"
214
+
215
+ def refuse(what: str) -> VerificationError:
216
+ return VerificationError(
217
+ f"the agent worktree {worktree} is not bound to the repository at {common}: "
218
+ f"{what}. The controller reads a worktree only through the registry entry that "
219
+ "names it; if the worktree or the repository was moved, run `git worktree repair` "
220
+ f"from the repository (or `git worktree repair {worktree}`), then 'resume'"
221
+ )
222
+
223
+ entries = _registry_entries(registry, refuse)
224
+ if entries is None:
225
+ raise refuse(f"the repository registers no worktree ({registry} is absent)")
226
+ matches = [e.admin for e in entries if _names(e.gitdir, e.admin, dotgit)]
227
+ if not matches:
228
+ raise refuse(f"no entry of {registry} has a gitdir naming {dotgit}")
229
+ if len(matches) > 1:
230
+ names = ", ".join(sorted(m.name for m in matches))
231
+ raise refuse(f"more than one entry of {registry} ({names}) has a gitdir naming {dotgit}")
232
+ admin = matches[0]
233
+ value = _read_pointer(dotgit)
234
+ if value is None or not value.startswith("gitdir: "):
235
+ raise refuse(f"{dotgit} is not a regular file of the form 'gitdir: <path>'")
236
+ if not _names(value[len("gitdir: ") :].strip(), worktree, admin):
237
+ raise refuse(f"{dotgit} does not name the registry entry {admin}")
238
+ return admin
239
+
240
+
241
+ def _less_dotgit(path: str) -> str:
242
+ """``path`` less a trailing ``/.git``: how git names a worktree after its ``.git``."""
243
+ return path[: -len("/.git")] if path.endswith("/.git") else path
244
+
245
+
246
+ def registered_worktree_roots(common: Path) -> set[Path]:
247
+ """The working-tree roots the repository's registry names, resolved (D6.4, #223).
248
+
249
+ What ``git worktree list`` reports, read without git: git also resolves
250
+ every listed worktree's ``HEAD``, following a link an agent planted
251
+ there to any file on the host (a FIFO stalls it). Nothing here reads a
252
+ ``HEAD`` or a reference.
253
+
254
+ - The main worktree, named as git names it: the common dir, less a
255
+ trailing ``/.git``.
256
+ - Each entry ``<common>/worktrees/<id>`` that is a real directory with
257
+ a regular ``gitdir`` file (:func:`_read_pointer`), at the path that
258
+ file names less a trailing ``/.git``, absolute or relative to the
259
+ entry.
260
+ - Left out, as git marks it ``prunable``: an entry whose ``gitdir``
261
+ names a ``.git`` that is not there, unless the entry is ``locked``.
262
+ Both are ``lstat``-ed, so a link there counts as there and is not
263
+ followed.
264
+
265
+ An entry that is a link, or whose ``gitdir`` is not a regular file,
266
+ names no root, since git would read through it; the binding
267
+ (:func:`bound_admin_dir`) refuses such a worktree too. A registry that
268
+ is a link, is not a directory, cannot be listed, or holds more than
269
+ :data:`MAX_REGISTRY_ENTRIES` entries, is a :class:`VerificationError`
270
+ (:func:`_registry_entries`).
271
+ """
272
+ roots = {Path(os.path.realpath(_less_dotgit(os.path.realpath(common)) or "/"))}
273
+ registry = common / "worktrees"
274
+
275
+ def refuse(what: str) -> VerificationError:
276
+ return VerificationError(
277
+ f"cannot list the repository's worktrees: {what}. The controller neither reuses an "
278
+ "agent worktree nor launches in a sandbox it cannot check against the registry"
279
+ )
280
+
281
+ for entry in _registry_entries(registry, refuse) or (): # None: no linked worktree
282
+ dotgit = os.path.join(entry.admin, entry.gitdir) # an absolute value stands alone
283
+ if not (os.path.lexists(dotgit) or entry.locked):
284
+ continue # prunable: nothing at its path is a worktree
285
+ roots.add(Path(os.path.realpath(os.path.join(entry.admin, _less_dotgit(entry.gitdir)))))
286
+ return roots
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class Candidate:
291
+ """The worktree's ``HEAD`` as the controller reads it (D6.3).
292
+
293
+ ``attached``: ``HEAD`` is a symbolic reference. ``head``: the commit it
294
+ names, "" when it is attached or names none the shared store holds.
295
+ ``problem``: why there is no ``HEAD`` to read at all (not a regular
296
+ file), else "".
297
+ """
298
+
299
+ attached: bool
300
+ head: str
301
+ problem: str = ""
302
+
303
+
304
+ def _admin_git(
305
+ common: Path, args: list[str], runner: Runner | None, what: str, worktree: str
306
+ ) -> tuple[int, str]:
307
+ res = _run(
308
+ runner,
309
+ local_git_request(["--git-dir", str(common), *args], timeout_seconds=GIT_TIMEOUT_SECONDS),
310
+ )
311
+ if res.timed_out or res.truncated or res.exit_code not in (0, 1):
312
+ raise VerificationError(
313
+ f"cannot read {what} of the agent worktree {worktree} (`git {' '.join(args)}` "
314
+ f"{_ended(res)}{_detail(res)}); nothing was pushed. 'resume' reads it again"
315
+ )
316
+ return res.exit_code, res.stdout.strip()
317
+
318
+
319
+ def head_problem(common: Path, admin: Path) -> str | None:
320
+ """Why git may not read the admin dir's ``HEAD``, or ``None`` (D6.3).
321
+
322
+ ``lstat``, no link followed: anything but a regular file is named,
323
+ since git follows a link there (to a file the agent wrote outside the
324
+ store, invisible inside) and reads the commit it names. So is a link or
325
+ another file type at the admin dir or the registry above it
326
+ (:func:`_root_problem`), which git would read ``worktrees/<id>/HEAD``
327
+ through (#223). Checked before ``HEAD`` is read at all, by the
328
+ candidate read and by the import.
329
+ """
330
+ problem = _root_problem(common, admin)
331
+ if problem:
332
+ return problem
333
+ head_file = admin / "HEAD"
334
+ try:
335
+ mode = os.lstat(head_file).st_mode
336
+ except OSError as exc:
337
+ return f"{head_file} cannot be read ({exc.strerror})"
338
+ if not stat.S_ISREG(mode):
339
+ return f"{head_file} is not a regular file"
340
+ return None
341
+
342
+
343
+ def read_candidate(common: Path, admin: Path, worktree: str, runner: Runner | None) -> Candidate:
344
+ """The candidate: ``worktrees/<id>/HEAD`` addressed through the common dir (D6.3).
345
+
346
+ Whether ``HEAD`` is attached is asked with ``symbolic-ref --no-recurse``,
347
+ which reads ``HEAD`` alone: without it git resolves the reference
348
+ ``HEAD`` names, and so a link the agent planted at
349
+ ``worktrees/<id>/refs/...`` to any file on the host (#223). An
350
+ inconclusive git read is a :class:`VerificationError`, never a guess.
351
+ """
352
+ problem = head_problem(common, admin)
353
+ if problem:
354
+ return Candidate(False, "", problem)
355
+ ref = f"worktrees/{admin.name}/HEAD"
356
+ attached, _ = _admin_git(
357
+ common,
358
+ ["symbolic-ref", "-q", "--no-recurse", ref],
359
+ runner,
360
+ "whether HEAD is detached",
361
+ worktree,
362
+ )
363
+ if attached == 0:
364
+ # Never a candidate: the reference it names (one the agent may have
365
+ # written in its admin dir) is neither opened nor resolved.
366
+ return Candidate(True, "")
367
+ found, head = _admin_git(
368
+ common,
369
+ ["rev-parse", "--verify", "-q", f"{ref}^{{commit}}"],
370
+ runner,
371
+ "the HEAD commit",
372
+ worktree,
373
+ )
374
+ if found != 0 or not _SHA_RE.fullmatch(head):
375
+ head = ""
376
+ return Candidate(False, head)
377
+
378
+
379
+ def _root_problem(common: Path, directory: Path) -> str | None:
380
+ """Why ``directory`` is not a real directory below ``common``, or ``None``.
381
+
382
+ Every component from ``common`` down to ``directory`` is ``lstat``-ed:
383
+ one that is a link, not a directory, or cannot be checked is named, so
384
+ nothing is read through it.
385
+ """
386
+ try:
387
+ parts = directory.relative_to(common).parts
388
+ except ValueError:
389
+ parts = ()
390
+ if not parts or ".." in parts:
391
+ return f"{directory} is not a directory below the git common dir {common}"
392
+ path = common
393
+ for part in parts:
394
+ path = path / part
395
+ try:
396
+ mode = os.lstat(path).st_mode
397
+ except OSError as exc:
398
+ return f"{path} cannot be checked ({exc.strerror})"
399
+ if stat.S_ISLNK(mode):
400
+ return f"{path} is a link"
401
+ if not stat.S_ISDIR(mode):
402
+ return f"{path} is not a directory"
403
+ return None
404
+
405
+
406
+ def private_objects_problem(common: Path, private: Path) -> str | None:
407
+ """Why the private object directory may not be read, or ``None`` (D6.5).
408
+
409
+ Walked without following a link. The directory itself, and each one
410
+ between the git common dir ``common`` and it, must be a real directory:
411
+ a link, another file type or one that cannot be checked is named, never
412
+ taken for an empty store. Inside it, an alternates file, a link, a
413
+ socket, a FIFO, a block device or a character device other than an
414
+ overlay whiteout (0:0, skipped) is named; nothing is opened.
415
+ """
416
+ problem = _root_problem(common, private)
417
+ if problem:
418
+ return problem
419
+ for name in ALTERNATES_FILES:
420
+ try:
421
+ os.lstat(private / name)
422
+ except FileNotFoundError:
423
+ continue
424
+ except OSError as exc:
425
+ return f"{private / name} cannot be checked ({exc.strerror})"
426
+ return f"{private / name} exists: it would make git read another object store"
427
+ stack = [private]
428
+ seen = 0
429
+ while stack:
430
+ directory = stack.pop()
431
+ try:
432
+ with os.scandir(directory) as entries:
433
+ for entry in entries:
434
+ seen += 1
435
+ if seen > MAX_PRIVATE_ENTRIES:
436
+ return f"{private} holds more than {MAX_PRIVATE_ENTRIES} entries"
437
+ st = entry.stat(follow_symlinks=False)
438
+ if stat.S_ISDIR(st.st_mode):
439
+ stack.append(Path(entry.path))
440
+ elif stat.S_ISREG(st.st_mode):
441
+ continue
442
+ elif stat.S_ISCHR(st.st_mode) and st.st_rdev == 0:
443
+ continue # an overlay whiteout: a shared object hidden inside
444
+ else:
445
+ return f"{entry.path} is neither a directory, a regular file nor a whiteout"
446
+ except FileNotFoundError:
447
+ continue
448
+ except OSError as exc:
449
+ return f"{directory} cannot be walked ({exc.strerror})"
450
+ return None
451
+
452
+
453
+ def holds_objects(private: Path) -> bool:
454
+ """Whether the private object directory has anything to import or refuse.
455
+
456
+ ``False`` only when it is absent, or a real directory (``lstat``, no
457
+ link followed) without an entry. A link, another file type, or a
458
+ directory that cannot be checked or listed is ``True``, so that the
459
+ import's walk (:func:`private_objects_problem`) refuses it rather than
460
+ taking it for an empty store.
461
+ """
462
+ try:
463
+ if not stat.S_ISDIR(os.lstat(private).st_mode):
464
+ return True
465
+ with os.scandir(private) as entries:
466
+ return any(True for _ in entries)
467
+ except FileNotFoundError:
468
+ return False
469
+ except OSError:
470
+ return True
471
+
472
+
473
+ def alternate_entry(directory: Path) -> str:
474
+ """``directory`` as the one entry of ``GIT_ALTERNATE_OBJECT_DIRECTORIES`` (#224).
475
+
476
+ Git splits that variable on ``:`` unless an entry starts with ``"``,
477
+ which it then unquotes C-style. So the entry is always double-quoted,
478
+ with ``\\`` and ``"`` escaped; every other byte, ``:`` included, passes
479
+ verbatim. Unquoted, a path with a ``:`` names two directories that do
480
+ not exist, git reads no object through either, and the import would
481
+ take the agent's commit for none.
482
+ """
483
+ escaped = str(directory).replace("\\", "\\\\").replace('"', '\\"')
484
+ return f'"{escaped}"'
485
+
486
+
487
+ class ObjectImportError(VerificationError):
488
+ """The strict import did not complete; nothing was published."""
489
+
490
+
491
+ def import_private_objects(
492
+ common: Path,
493
+ admin: Path,
494
+ private: Path,
495
+ runner: Runner | None,
496
+ *,
497
+ exclude: tuple[str, ...] = (),
498
+ git_lock: Callable[[str], AbstractContextManager[object]] | None = None,
499
+ ) -> str:
500
+ """Import the objects ``HEAD`` reaches that the shared store lacks (D6.5).
501
+
502
+ Returns the ``HEAD`` commit the import covered ("" when the directory
503
+ holds nothing, or ``HEAD`` names no commit even with it). The walk of
504
+ :func:`private_objects_problem` comes first: tampering is a
505
+ :class:`SandboxTamperError`. A ``HEAD`` that is not a regular file
506
+ (:func:`head_problem`, the check :func:`read_candidate` makes) is an
507
+ :class:`ObjectImportError` before git reads anything: nothing is packed
508
+ or imported, and the candidate read refuses the candidate with it. The
509
+ commit is the object id ``HEAD`` holds, read without following a link;
510
+ an attached ``HEAD`` imports nothing (it is never a candidate). Then
511
+ ``rev-list --objects <head> --not --branches --tags --remotes
512
+ <exclude>`` with the private directory as an alternate, the objects the
513
+ shared store does not hold, ``pack-objects`` of exactly those, and
514
+ ``index-pack --stdin --fix-thin --strict`` into the shared store.
515
+ Idempotent; a failure is :class:`ObjectImportError` and is not retried
516
+ here. ``exclude`` are commits the shared store holds (the phase's
517
+ recorded base), so the walk stops there.
518
+
519
+ Only ``index-pack`` writes the shared store: it runs under the
520
+ shared-Git lock (L1, ADR 0008 D12.1) that ``git_lock(operation)``
521
+ returns, by default :class:`~autoforge.locking.SharedGitLock` on
522
+ ``common``, and with automatic maintenance off (D12.3). The walk,
523
+ ``rev-list`` and ``pack-objects`` read the store and write only a
524
+ private scratch directory. A lock not obtained within its bound is this
525
+ import failing (:class:`ObjectImportError`, D11.3).
526
+ """
527
+ if not holds_objects(private):
528
+ return ""
529
+ problem = private_objects_problem(common, private)
530
+ if problem:
531
+ raise SandboxTamperError(
532
+ f"the private object directory {private} of the agent worktree has been "
533
+ f"tampered with: {problem}. Nothing was read from it and nothing was published; "
534
+ "inspect it, and remove what does not belong there before unblocking the run"
535
+ )
536
+ problem = head_problem(common, admin)
537
+ if problem:
538
+ raise ObjectImportError(
539
+ f"the strict import of the agent's commits from {private} did not start: "
540
+ f"{problem}, and git would follow it; nothing was read or published"
541
+ )
542
+ alternate = {"GIT_ALTERNATE_OBJECT_DIRECTORIES": alternate_entry(private)}
543
+ git_dir = ["--git-dir", str(common)]
544
+
545
+ def git(
546
+ args: list[str],
547
+ what: str,
548
+ *,
549
+ env: dict[str, str] | None = None,
550
+ stdin: bytes | None = None,
551
+ allow: tuple[int, ...] = (0,),
552
+ ) -> ExecutionResult:
553
+ request = local_git_request(
554
+ [*git_dir, *args], env=env, timeout_seconds=IMPORT_TIMEOUT_SECONDS
555
+ )
556
+ request.stdin_data = stdin
557
+ res = _run(runner, request)
558
+ if res.timed_out or res.truncated or res.exit_code not in allow:
559
+ raise ObjectImportError(
560
+ f"the strict import of the agent's commits from {private} failed: {what} "
561
+ f"{_ended(res)}{_detail(res)}; nothing was published"
562
+ )
563
+ return res
564
+
565
+ detached = _read_pointer(admin / "HEAD")
566
+ if detached is None or not _SHA_RE.fullmatch(detached):
567
+ # Attached (``ref: ...``), or no object id: no candidate to cover,
568
+ # and no reference the agent wrote is resolved. The candidate read
569
+ # says why there is none.
570
+ return ""
571
+ found = git(
572
+ ["rev-parse", "--verify", "-q", f"{detached}^{{commit}}"],
573
+ "reading HEAD",
574
+ env=alternate,
575
+ allow=(0, 1),
576
+ )
577
+ head = found.stdout.strip()
578
+ if found.exit_code != 0 or not _SHA_RE.fullmatch(head):
579
+ return ""
580
+ present = _present(git, [head, *exclude])
581
+ if head in present:
582
+ return head
583
+ listed = git(
584
+ [
585
+ "rev-list",
586
+ "--objects",
587
+ head,
588
+ "--not",
589
+ "--branches",
590
+ "--tags",
591
+ "--remotes",
592
+ *[sha for sha in exclude if sha in present],
593
+ ],
594
+ "listing the objects HEAD reaches",
595
+ env=alternate,
596
+ )
597
+ lines = [line for line in listed.stdout.splitlines() if line.strip()]
598
+ ids = [line.split(" ", 1)[0] for line in lines]
599
+ missing = set(ids) - _present(git, ids)
600
+ wanted = [line for line in lines if line.split(" ", 1)[0] in missing]
601
+ if not wanted:
602
+ return head
603
+ scratch = tempfile.mkdtemp(prefix="autoforge-import-")
604
+ try:
605
+ base = os.path.join(scratch, "import")
606
+ git(
607
+ ["pack-objects", "-q", base],
608
+ "packing the agent's objects",
609
+ env=alternate,
610
+ stdin=("\n".join(wanted) + "\n").encode(),
611
+ )
612
+ packs = [name for name in os.listdir(scratch) if name.endswith(".pack")]
613
+ if len(packs) != 1:
614
+ raise ObjectImportError(
615
+ f"the strict import of the agent's commits from {private} failed: "
616
+ f"pack-objects wrote {len(packs)} packs; nothing was published"
617
+ )
618
+ with open(os.path.join(scratch, packs[0]), "rb") as handle:
619
+ data = handle.read()
620
+ lock = git_lock or (lambda operation: SharedGitLock(common, operation=operation))
621
+ try:
622
+ with lock("import the agent's objects into the shared store"):
623
+ git(
624
+ [*NO_AUTO_MAINTENANCE, "index-pack", "--stdin", "--fix-thin", "--strict"],
625
+ "indexing the pack strictly into the shared store",
626
+ stdin=data,
627
+ )
628
+ except (GitLockTimeout, LockError) as exc:
629
+ raise ObjectImportError(
630
+ f"the strict import of the agent's commits from {private} failed: {exc}; "
631
+ "nothing was published"
632
+ ) from exc
633
+ finally:
634
+ shutil.rmtree(scratch, ignore_errors=True)
635
+ if head not in _present(git, [head]):
636
+ raise ObjectImportError(
637
+ f"the strict import of the agent's commits from {private} left HEAD {head} "
638
+ "outside the shared object store; nothing was published"
639
+ )
640
+ return head
641
+
642
+
643
+ def _present(git: Callable[..., ExecutionResult], ids: list[str]) -> set[str]:
644
+ """Which of ``ids`` the shared store itself holds (no alternate)."""
645
+ unique = list(dict.fromkeys(i for i in ids if _SHA_RE.fullmatch(i)))
646
+ if not unique:
647
+ return set()
648
+ res = git(
649
+ ["cat-file", "--batch-check=%(objectname)"],
650
+ "checking which objects the shared store holds",
651
+ stdin=("\n".join(unique) + "\n").encode(),
652
+ )
653
+ return {line.strip() for line in res.stdout.splitlines() if _SHA_RE.fullmatch(line.strip())}