gitgrip 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,1019 @@
1
+ """Executor for the neutral MaterializationPlan `clone` operation (S4-B).
2
+
3
+ S4-A landed the plan CONTRACT: validate -> un-forgeable ValidatedPlan
4
+ capability -> durable receipt. This is the first thing that consumes it.
5
+
6
+ Contract: MaterializationPlan v1 clone contract and acceptance fruit 6/7/8/9.
7
+
8
+ Why this is its own module rather than more of `gitops.py`: gitops is a thin
9
+ subprocess layer over git. The substance here is POLICY -- which object sharing
10
+ is permitted, what makes a clone state-isolated, when an existing clone may be
11
+ reused. Policy living in the primitive layer is policy that later callers
12
+ bypass by reaching for the primitive directly.
13
+
14
+ Design note carried from the S4-A review cycle, applied while designing rather
15
+ than while testing: for every guard below, the question was what ELSE would
16
+ reject this input first, and whether the guard is therefore untested at its own
17
+ level. Five masking pairs came out of that and are recorded in the test module;
18
+ the two that shaped this code most:
19
+
20
+ - `_read_alternate_entries` returns a SET and callers compare with `==`, never
21
+ `all(...)`. An empty alternates file makes "every entry is the declared
22
+ cache" vacuously true, which is exactly how a clone with no object sharing
23
+ at all passes a guard written to require object sharing.
24
+ - `verify_clone_isolation` re-runs `verify_cache_provenance` on the declared
25
+ reference instead of trusting it. Set-equality alone proves the clone points
26
+ where the PLAN said; it says nothing about whether the plan pointed at a
27
+ legitimate cache, and another unit's bare mirror of the same repository
28
+ satisfies every other check.
29
+
30
+ And one property of git that the whole verification posture rests on:
31
+ `--reference-if-able` SILENTLY DEGRADES. Against a missing cache it exits 0 and
32
+ writes no alternates file at all. Section 8.2 chooses that flag deliberately so
33
+ a cold cache cannot fail the timed path, which means a declared reference is a
34
+ CLAIM the executor must verify positively afterwards. Passing the flag is not
35
+ evidence the alternate exists.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import dataclasses
41
+ import hashlib
42
+ import os
43
+ import re
44
+ import shutil
45
+ import stat
46
+ import subprocess
47
+ import tempfile
48
+ import time
49
+ from pathlib import Path
50
+
51
+ from . import gitops
52
+ from .spec_apply import (
53
+ MaterializationPlanError,
54
+ ValidatedPlan,
55
+ _read_canonical_workspace_spec_bytes,
56
+ canonicalize_workspace_path,
57
+ workspace_cache_root,
58
+ )
59
+
60
+
61
+ @dataclasses.dataclass(frozen=True)
62
+ class _CloneBinding:
63
+ """One immutable reading of a clone operation, taken from A's frozen plan
64
+ snapshot before any caller-reachable code runs.
65
+
66
+ Mirrors A's _ConsumptionBinding for the same reason: verification and use
67
+ must not be separated by anything that can run caller code. Holding the
68
+ fields instead of re-reading the capability makes a post-verification swap
69
+ irrelevant rather than merely detectable."""
70
+
71
+ repo_url: str
72
+ branch: str
73
+ dest_path: str
74
+ reference_base: str | None
75
+
76
+
77
+ class CloneExecutionError(MaterializationPlanError):
78
+ """A validated plan's clone operation could not be executed safely.
79
+
80
+ Subclasses the contract's error so callers catching the family keep
81
+ working, while the type still names which layer refused."""
82
+
83
+
84
+ class IncompleteRemoval(CloneExecutionError):
85
+ """A directory a caller asked to be removed is still (partially) present."""
86
+
87
+
88
+ def rmtree_or_refuse(path: Path) -> None:
89
+ """Remove a directory tree, then verify it is actually gone.
90
+
91
+ ``shutil.rmtree(path, ignore_errors=True)`` silently drops any failure --
92
+ a locked file, a permission-denied entry, a busy mount point -- and every
93
+ call site that used it alone had no way to distinguish "cleaned" from
94
+ "partially cleaned," several of them going on to report success (a
95
+ "lane discarded" message, a silent fall-through to reuse the winner's
96
+ lane) regardless. `shutil.rmtree` only removes a directory's OWN entry as
97
+ its LAST step, so if anything beneath it survives, the directory itself
98
+ still exists -- checking `path.exists()` after the attempt is therefore a
99
+ complete detector for "did the whole tree go," not a sample of it. Raises,
100
+ naming one surviving entry, if it did not; a caller that wants a softer
101
+ outcome (log and continue) catches `IncompleteRemoval` explicitly rather
102
+ than getting silence by default."""
103
+ shutil.rmtree(path, ignore_errors=True)
104
+ if not path.exists():
105
+ return
106
+ try:
107
+ remaining = sorted(str(p) for p in path.rglob("*"))
108
+ except OSError:
109
+ remaining = []
110
+ example = remaining[0] if remaining else str(path)
111
+ raise IncompleteRemoval(
112
+ f"{path} could not be fully removed; {len(remaining)} entr"
113
+ f"{'y' if len(remaining) == 1 else 'ies'} beneath it remain, "
114
+ f"including {example}"
115
+ )
116
+
117
+
118
+ # Section 8.1 names these as the mutable state a clone must OWN. The
119
+ # --git-common-dir check proves only that the .git ROOT is local; every entry
120
+ # beneath it can be redirected individually, which is how a clone with a
121
+ # perfectly local common dir still reads another unit's refs or object store
122
+ # (Sentinel, #803 review at bd7afe5).
123
+ #
124
+ # `objects` is on this list for a reason worth stating: object sharing has TWO
125
+ # routes, and the alternates file is only one of them. Symlinking the objects
126
+ # directory itself shares the store with no alternates file to inspect -- so a
127
+ # plan declaring no reference_base passes the alternates check vacuously while
128
+ # the clone is fully joined to another unit's objects.
129
+ _CLONE_LOCAL_GIT_ENTRIES = (
130
+ "objects",
131
+ "refs",
132
+ "index",
133
+ "HEAD",
134
+ "config",
135
+ "logs",
136
+ "packed-refs",
137
+ )
138
+
139
+ _SHA40 = re.compile(r"\A[0-9a-f]{40}\Z")
140
+
141
+
142
+ def _alternates_file(clone_root: Path) -> Path:
143
+ return clone_root / ".git" / "objects" / "info" / "alternates"
144
+
145
+
146
+ def _require_local_git_internals(clone_root: Path, git_dir: Path) -> None:
147
+ """Every entry section 8.1 requires the clone to own must be its own, not a
148
+ redirection. lstat, never stat: stat() follows the link and reports the
149
+ target, which is precisely the thing being hidden."""
150
+ for name in _CLONE_LOCAL_GIT_ENTRIES:
151
+ entry = git_dir / name
152
+ try:
153
+ mode = os.lstat(entry).st_mode
154
+ except FileNotFoundError:
155
+ continue
156
+ if stat.S_ISLNK(mode):
157
+ raise CloneExecutionError(
158
+ f"clone at {clone_root} redirects .git/{name} through a symlink to "
159
+ f"{os.readlink(entry)!r} -- section 8.1 requires clone-local refs, "
160
+ "index, locks, config and objects, and a local .git root does not "
161
+ "make the state beneath it local"
162
+ )
163
+
164
+
165
+ def _require_complete_history(clone_root: Path, git_dir: Path) -> None:
166
+ """Section 8.1: the clone must hold the complete reachable history required
167
+ by the profile, and the v1 plan has no shallow or partial profile to opt
168
+ into. A depth-1 clone is otherwise indistinguishable from a healthy one --
169
+ correct origin, local git dir, clean tree, valid alternate -- so nothing
170
+ else in this verifier can see it (Sentinel, #803 review at bd7afe5)."""
171
+ if (git_dir / "shallow").exists():
172
+ raise CloneExecutionError(
173
+ f"clone at {clone_root} is shallow (.git/shallow present) -- section 8.1 "
174
+ "requires the complete reachable history and the plan declares no shallow "
175
+ "profile"
176
+ )
177
+ proc = gitops.git(clone_root, "rev-parse", "--is-shallow-repository")
178
+ if proc.returncode == 0 and proc.stdout.strip() == "true":
179
+ raise CloneExecutionError(
180
+ f"clone at {clone_root} reports itself shallow -- section 8.1 requires the "
181
+ "complete reachable history"
182
+ )
183
+ for key in ("remote.origin.promisor", "remote.origin.partialclonefilter"):
184
+ probe = gitops.git(clone_root, "config", "--get", key)
185
+ if probe.returncode == 0 and probe.stdout.strip():
186
+ raise CloneExecutionError(
187
+ f"clone at {clone_root} is a partial clone ({key}="
188
+ f"{probe.stdout.strip()!r}) -- its history is fetched lazily from the "
189
+ "network, which section 8.1's complete-history requirement excludes"
190
+ )
191
+
192
+
193
+ def _working_tree_state(clone_root: Path) -> str:
194
+ """clean | dirty | unreadable.
195
+
196
+ Three outcomes, not two. gitops.repo_dirty() maps EVERY nonzero exit to
197
+ False, so a corrupt .git/index reads as 'clean' and a damaged clone is
198
+ reused as healthy (Sentinel, #803 review at bd7afe5). Collapsing the
199
+ unreadable case into 'clean' is a fail-open: the one state we understand
200
+ least becomes the one we treat most permissively.
201
+
202
+ Deliberately local rather than a fix to gitops.repo_dirty(): that helper is
203
+ live in app.py and syncops.py, and changing shared dirtiness semantics
204
+ underneath two other call paths does not belong in this PR. Filed
205
+ separately."""
206
+ proc = gitops.git(clone_root, "status", "--porcelain")
207
+ if proc.returncode != 0:
208
+ return "unreadable"
209
+ return "dirty" if proc.stdout.strip() else "clean"
210
+
211
+
212
+ def _read_alternates_of(object_dir: Path) -> set[Path]:
213
+ """Alternate object directories declared by an arbitrary object database.
214
+
215
+ Relative entries resolve against the OBJECT DIRECTORY, which is git's rule.
216
+ Resolving them against the process working directory -- the obvious
217
+ Path(line).resolve() -- makes this verifier and git disagree about what the
218
+ clone is actually reading from, and a verifier that disagrees with git is
219
+ worse than no verifier (Atlas, #803 review at bd7afe5)."""
220
+ path = object_dir / "info" / "alternates"
221
+ if not path.exists():
222
+ return set()
223
+ entries = set()
224
+ for line in path.read_text().splitlines():
225
+ line = line.strip()
226
+ if not line:
227
+ continue
228
+ candidate = Path(line)
229
+ if not candidate.is_absolute():
230
+ candidate = object_dir / candidate
231
+ entries.add(candidate.resolve())
232
+ return entries
233
+
234
+
235
+ def _read_alternate_entries(clone_root: Path) -> set[Path]:
236
+ """Resolved alternate object directories declared by this clone.
237
+
238
+ Blank lines are dropped, so a whitespace-only file reads as empty rather
239
+ than as one weird entry pointing at the process working directory.
240
+
241
+ A SET, not a list: the same permitted cache listed twice is the same object
242
+ sharing, and git tolerates the duplicate. Deduplicating here means the
243
+ permitted-set comparison judges WHICH object stores are reachable, not how
244
+ many times the file happens to name them.
245
+
246
+ Emptiness is deliberately NOT encoded as "a set that fails an equality" --
247
+ callers claim it in an explicit branch first. Relying on the equality to
248
+ reject empty is the vacuous-truth trap: it holds for `==` and silently
249
+ fails for the `all(entry in permitted)` spelling that any later reader may
250
+ think is the same check."""
251
+ return _read_alternates_of(clone_root / ".git" / "objects")
252
+
253
+
254
+ def verify_cache_provenance(cache_root: Path, *, workspace_root: Path, repo_url: str) -> None:
255
+ """Section 8.2: object sharing is permitted ONLY against the workspace-managed
256
+ cache, seeded from the declared upstream.
257
+
258
+ The three checks are orthogonal and each one is the only thing that can see
259
+ its own failure:
260
+
261
+ - containment rejects a bare mirror of the RIGHT repository sitting at the
262
+ wrong place (another unit's directory, the team clone). Provenance
263
+ cannot see it, because its origin matches.
264
+ - bare-repository rejects a working clone parked under the cache root.
265
+ - provenance rejects a cache under the right path, bare, seeded from a
266
+ different upstream.
267
+
268
+ Containment is what keeps object bytes from crossing a unit boundary, which
269
+ is the isolation seam the whole design refuses to open."""
270
+ cache_resolved = cache_root.resolve()
271
+ permitted_root = workspace_cache_root(workspace_root).resolve()
272
+ if cache_resolved == permitted_root or not cache_resolved.is_relative_to(permitted_root):
273
+ raise CloneExecutionError(
274
+ f"reference {cache_root} is not inside the workspace object cache "
275
+ f"({permitted_root}) -- section 8.2 permits object sharing only with the "
276
+ "workspace-managed cache, never with another unit or the team clone"
277
+ )
278
+ if not cache_resolved.exists():
279
+ raise CloneExecutionError(
280
+ f"declared object cache {cache_root} does not exist -- it must be seeded "
281
+ "before any clone references it"
282
+ )
283
+ if not gitops.is_git_dir(cache_resolved):
284
+ raise CloneExecutionError(
285
+ f"declared object cache {cache_root} is not a bare git repository"
286
+ )
287
+ actual_url = gitops.remote_origin_url(cache_resolved)
288
+ if actual_url != repo_url:
289
+ raise CloneExecutionError(
290
+ f"declared object cache {cache_root} was seeded from {actual_url!r}, "
291
+ f"not from the declared upstream {repo_url!r}"
292
+ )
293
+
294
+ # Containment is only ONE HOP without this (Atlas, #803 review at bd7afe5).
295
+ # A cache that sits at the right path, is bare, and carries the right origin
296
+ # can still reach outside the workspace two ways, and the unit clone inherits
297
+ # whatever it reaches: the cache's own objects dir may be a symlink, or the
298
+ # cache may declare its own alternate pointing at a machine-global store.
299
+ # Either one re-opens exactly the isolation seam section 8.2 refuses to open,
300
+ # one level down where the clone-side check cannot see it.
301
+ cache_objects = cache_resolved / "objects"
302
+ if stat.S_ISLNK(os.lstat(cache_objects).st_mode):
303
+ raise CloneExecutionError(
304
+ f"declared object cache {cache_root} redirects its objects directory "
305
+ f"through a symlink to {os.readlink(cache_objects)!r} -- the clone would "
306
+ "share objects with an undeclared store one hop out"
307
+ )
308
+ transitive = _read_alternates_of(cache_objects)
309
+ if transitive:
310
+ raise CloneExecutionError(
311
+ f"declared object cache {cache_root} declares its own alternate(s) "
312
+ f"{sorted(str(e) for e in transitive)} -- object sharing must terminate at "
313
+ "the workspace cache, and a cache that alternates onward makes every clone "
314
+ "reachable into an undeclared store (section 8.2)"
315
+ )
316
+
317
+
318
+ def verify_clone_isolation(
319
+ clone_root: Path,
320
+ *,
321
+ workspace_root: Path,
322
+ repo_url: str,
323
+ reference_base: Path | None,
324
+ ) -> None:
325
+ """Section 8.1 (independent mutable state) + 8.2 (allowed object sharing).
326
+
327
+ Runs on STAGING before publication and again on any existing clone before
328
+ reuse, because the two paths admit the same failures by different routes:
329
+ a fresh clone can silently lose its alternate, an existing directory can be
330
+ a worktree somebody parked there.
331
+
332
+ Deliberately does not consult `gitops.is_git_repo()`: that asks
333
+ "is-inside-work-tree", which answers TRUE inside a linked worktree. The
334
+ forbidden shape looks healthy to the obvious helper, so the checks here are
335
+ positive statements about this clone's OWN state."""
336
+ git_dir = clone_root / ".git"
337
+ # lstat FIRST, and never Path.is_dir(). is_dir() follows the link, so a .git
338
+ # that is itself a symlink into another same-origin clone answers True --
339
+ # and then --git-common-dir resolves THROUGH that same link, so both sides of
340
+ # the common-dir comparison land on the foreign directory and agree. Two
341
+ # guards, one symlink, both satisfied (Atlas, #803 review at bd7afe5).
342
+ try:
343
+ git_mode = os.lstat(git_dir).st_mode
344
+ except FileNotFoundError:
345
+ raise CloneExecutionError(
346
+ f"clone at {clone_root} has no .git -- it is not a git repository"
347
+ ) from None
348
+ if stat.S_ISLNK(git_mode):
349
+ raise CloneExecutionError(
350
+ f"clone at {clone_root} redirects .git through a symlink to "
351
+ f"{os.readlink(git_dir)!r} -- .git must be a directory the clone owns "
352
+ "(section 8.1)"
353
+ )
354
+ if not stat.S_ISDIR(git_mode):
355
+ raise CloneExecutionError(
356
+ f"clone at {clone_root} is not state-isolated: .git must be a directory, "
357
+ "not a worktree pointer file (section 8.1)"
358
+ )
359
+ _require_local_git_internals(clone_root, git_dir)
360
+ _require_complete_history(clone_root, git_dir)
361
+ if (git_dir / "worktrees").exists():
362
+ raise CloneExecutionError(
363
+ f"clone at {clone_root} hosts linked worktrees (.git/worktrees), which share "
364
+ "its refs and locks -- section 8.1 forbids the shape in both directions"
365
+ )
366
+
367
+ proc = gitops.git(clone_root, "rev-parse", "--git-common-dir")
368
+ if proc.returncode != 0:
369
+ raise CloneExecutionError(
370
+ f"clone at {clone_root} is not a readable git repository: "
371
+ f"{proc.stderr.strip() or proc.stdout.strip()}"
372
+ )
373
+ # Relative (".git") in a normal clone, absolute inside a worktree -- so it
374
+ # has to be resolved against the clone root before it means anything.
375
+ common_dir = Path(os.path.join(clone_root, proc.stdout.strip())).resolve()
376
+ if common_dir != git_dir.resolve():
377
+ raise CloneExecutionError(
378
+ f"clone at {clone_root} resolves its git common directory to {common_dir}, "
379
+ "outside its own .git -- its refs, index, and locks are shared (section 8.1)"
380
+ )
381
+
382
+ actual_url = gitops.remote_origin_url(clone_root)
383
+ if actual_url != repo_url:
384
+ raise CloneExecutionError(
385
+ f"clone at {clone_root} has origin {actual_url!r}, not the declared {repo_url!r}"
386
+ )
387
+
388
+ entries = _read_alternate_entries(clone_root)
389
+ if reference_base is None:
390
+ if entries:
391
+ raise CloneExecutionError(
392
+ f"clone at {clone_root} declares alternate(s) "
393
+ f"{sorted(str(e) for e in entries)} but its plan operation declares no "
394
+ "reference_base -- undeclared object sharing is forbidden (section 8.2)"
395
+ )
396
+ return
397
+
398
+ verify_cache_provenance(reference_base, workspace_root=workspace_root, repo_url=repo_url)
399
+ expected = {(reference_base / "objects").resolve()}
400
+ if not entries:
401
+ raise CloneExecutionError(
402
+ f"clone at {clone_root} declares no alternate, but its plan operation "
403
+ f"declares reference_base {reference_base}. git's --reference-if-able "
404
+ "degrades silently, so an absent or empty alternates file is the expected "
405
+ "shape of a reference that never took -- not evidence that one did"
406
+ )
407
+ if entries != expected:
408
+ raise CloneExecutionError(
409
+ f"clone at {clone_root} declares alternate(s) "
410
+ f"{sorted(str(e) for e in entries)}, which is not exactly the declared "
411
+ f"workspace cache {sorted(str(e) for e in expected)} (section 8.2)"
412
+ )
413
+
414
+
415
+ def _require_workspace_binding(validated: ValidatedPlan, workspace_root: Path) -> None:
416
+ """The plan is bound to a WorkspaceSpec at VALIDATION; the executor takes
417
+ workspace_root as a separate argument. Nothing structurally stops a caller
418
+ from validating against one workspace and executing against another, and
419
+ every relative path in the plan would then resolve somewhere else.
420
+
421
+ Re-checking at USE rather than trusting the capability's field is the same
422
+ lesson the S4-A TOCTOU round ended on: verification belongs at the moment of
423
+ use, not only at the moment of construction."""
424
+ try:
425
+ spec_bytes = _read_canonical_workspace_spec_bytes(workspace_root)
426
+ except MaterializationPlanError as exc:
427
+ raise CloneExecutionError(
428
+ f"cannot execute against {workspace_root}: its canonical WorkspaceSpec is "
429
+ f"unreadable ({exc})"
430
+ ) from exc
431
+ actual = hashlib.sha256(spec_bytes).hexdigest()
432
+ if actual != validated.workspace_spec_sha256:
433
+ raise CloneExecutionError(
434
+ f"plan is bound to WorkspaceSpec {validated.workspace_spec_sha256} but "
435
+ f"{workspace_root} has {actual} -- executing a plan against a workspace it "
436
+ "was not validated for would resolve every declared path elsewhere"
437
+ )
438
+
439
+
440
+ def _git_clone(repo_url: str, target: Path, *, branch: str, reference: Path | None) -> None:
441
+ command = ["git", "clone", "--quiet"]
442
+ if reference is not None:
443
+ # section 8.2: --dissociate is intentionally omitted on the timed path.
444
+ # State isolation, not object duplication, is the invariant.
445
+ command.extend(["--reference-if-able", str(reference)])
446
+ command.extend(["--branch", branch, repo_url, str(target)])
447
+ proc = subprocess.run(command, capture_output=True, text=True, check=False)
448
+ if proc.returncode != 0:
449
+ raise CloneExecutionError(
450
+ f"failed to clone {repo_url} at branch {branch!r}:\n"
451
+ f"{proc.stderr.strip() or proc.stdout.strip()}"
452
+ )
453
+
454
+
455
+ def _reuse_existing_clone(
456
+ dest: Path, *, workspace_root: Path, repo_url: str, reference_base: Path | None
457
+ ) -> None:
458
+ """Section 8.3: a healthy clone is reused; a dirty one is NEVER reset or
459
+ replaced; a damaged or mismatched one blocks.
460
+
461
+ Isolation runs before the dirty check because "is this working tree dirty"
462
+ is not a meaningful question about a worktree pointer or a clone of some
463
+ other repository -- those are answered by refusing, not by inspecting.
464
+
465
+ The declared branch is deliberately NOT enforced on reuse. Section 8.1
466
+ requires a clone-local HEAD; the unit owns its branch state, and forcing it
467
+ back to the plan's branch would be exactly the destructive repair 8.3 puts
468
+ behind an explicit separate command."""
469
+ verify_clone_isolation(
470
+ dest,
471
+ workspace_root=workspace_root,
472
+ repo_url=repo_url,
473
+ reference_base=reference_base,
474
+ )
475
+ state = _working_tree_state(dest)
476
+ if state == "unreadable":
477
+ raise CloneExecutionError(
478
+ f"existing clone at {dest} cannot report its working-tree state -- it is "
479
+ "damaged (an unreadable index or object database). Section 8.3 blocks a "
480
+ "damaged clone rather than repairing it, and blocking here changes none of "
481
+ "its bytes"
482
+ )
483
+ if state == "dirty":
484
+ raise CloneExecutionError(
485
+ f"existing clone at {dest} is dirty -- section 8.3 never resets or replaces "
486
+ "a dirty clone; destructive repair requires an explicit separate command"
487
+ )
488
+ if gitops.current_head_sha(dest) is None:
489
+ raise CloneExecutionError(
490
+ f"existing clone at {dest} has no resolvable HEAD -- reuse requires a clone "
491
+ "that can actually be worked in, not merely one whose status command exits 0"
492
+ )
493
+
494
+
495
+ def execute_clone_operation(
496
+ validated: ValidatedPlan, index: int, *, workspace_root: Path
497
+ ) -> dict[str, object]:
498
+ """Execute the `clone` operation at `index` of a validated plan.
499
+
500
+ Returns neutral, receipt-shaped evidence for that operation. Addressing by
501
+ index rather than filtering for clone operations keeps evidence[i] aligned
502
+ with operation i, which is what S4-A's receipt screen requires: an executor
503
+ that silently skipped the kinds it does not handle would shift every later
504
+ result by one and still screen clean."""
505
+ # A plain Path, always. workspace_root is caller-supplied, and a Path
506
+ # SUBCLASS can run arbitrary code from __truediv__/resolve during the path
507
+ # work below -- which is the callback that reopens the window this binding
508
+ # exists to close. Normalising here removes the callback surface itself
509
+ # rather than trying to be safe around it.
510
+ workspace_root = Path(os.fspath(workspace_root))
511
+
512
+ validated.verify(require_provenance=True)
513
+ _require_workspace_binding(validated, workspace_root)
514
+
515
+ operations = validated.plan["operations"]
516
+ if not 0 <= index < len(operations):
517
+ raise CloneExecutionError(
518
+ f"operation index {index} is out of range for a plan with "
519
+ f"{len(operations)} operation(s)"
520
+ )
521
+ op = operations[index]
522
+ kind = op.get("kind")
523
+ if kind != "clone":
524
+ raise CloneExecutionError(
525
+ f"operations[{index}] is kind {kind!r}, not 'clone' -- its handler lands "
526
+ "with a later slice (venv/editable_install with S4-D, project_file with S4-C)"
527
+ )
528
+
529
+ # ONE immutable binding taken from A's frozen snapshot, before any path work
530
+ # runs, and no live capability read after it (Atlas, #803 review at bd7afe5).
531
+ # The previous shape verified, then did callback-capable path work, then
532
+ # re-read validated.plan -- so a capability swapped in between was cloned
533
+ # from while the sealed one was never touched. This is A's own consume()
534
+ # lesson: the fix is not to check again, it is to stop re-reading.
535
+ binding = _CloneBinding(
536
+ repo_url=str(op["repo_url"]),
537
+ branch=str(op["branch"]),
538
+ dest_path=str(op["dest_path"]),
539
+ reference_base=(
540
+ str(op["reference_base"]) if op.get("reference_base") is not None else None
541
+ ),
542
+ )
543
+
544
+ repo_url = binding.repo_url
545
+ branch = binding.branch
546
+ dest = canonicalize_workspace_path(
547
+ workspace_root, binding.dest_path, field_name=f"operations[{index}].dest_path"
548
+ )
549
+ declared_reference = binding.reference_base
550
+ reference_base = (
551
+ canonicalize_workspace_path(
552
+ workspace_root,
553
+ declared_reference,
554
+ field_name=f"operations[{index}].reference_base",
555
+ )
556
+ if declared_reference is not None
557
+ else None
558
+ )
559
+
560
+ if reference_base is not None:
561
+ # Before any work: cloning against a cache we would refuse afterwards
562
+ # only wastes the timed path and leaves staging to clean up.
563
+ verify_cache_provenance(reference_base, workspace_root=workspace_root, repo_url=repo_url)
564
+
565
+ reused = dest.exists()
566
+ if reused:
567
+ _reuse_existing_clone(
568
+ dest,
569
+ workspace_root=workspace_root,
570
+ repo_url=repo_url,
571
+ reference_base=reference_base,
572
+ )
573
+ else:
574
+ _stage_and_publish(
575
+ dest,
576
+ workspace_root=workspace_root,
577
+ repo_url=repo_url,
578
+ branch=branch,
579
+ reference_base=reference_base,
580
+ )
581
+
582
+ # Section 12.1 requires repo URL, destination, HEAD, clone-state evidence,
583
+ # and cache path plus APPROVED-ALTERNATE evidence. The previous shape echoed
584
+ # the declaration back (reference_base as planned) and called it evidence --
585
+ # hollow-green, because a receipt that repeats the plan proves nothing about
586
+ # what is on disk (Atlas, #803 review at bd7afe5). What is recorded here is
587
+ # what was OBSERVED and proven: the alternates git will actually read, and
588
+ # the isolation facts the verifier established.
589
+ observed_alternates = sorted(
590
+ _relativize(entry, workspace_root) for entry in _read_alternate_entries(dest)
591
+ )
592
+ return {
593
+ "kind": "clone",
594
+ "repo_url": binding.repo_url,
595
+ "dest_path": binding.dest_path,
596
+ "branch": gitops.current_branch(dest),
597
+ "head_sha": gitops.current_head_sha(dest) or "",
598
+ "cache_path": declared_reference,
599
+ "approved_alternates": observed_alternates,
600
+ "clone_state": {
601
+ "git_dir_local": True,
602
+ "complete_history": True,
603
+ "hosts_worktrees": False,
604
+ "working_tree": _working_tree_state(dest),
605
+ },
606
+ "reused": reused,
607
+ }
608
+
609
+
610
+ def _relativize(path: Path, workspace_root: Path) -> str:
611
+ """Workspace-relative when possible. An absolute path inside a receipt is
612
+ not wrong, but it makes two differently-rooted clean runs produce different
613
+ receipts for identical work -- which acceptance fruit 16 forbids."""
614
+ try:
615
+ return str(path.relative_to(workspace_root.resolve()))
616
+ except ValueError:
617
+ return str(path)
618
+
619
+
620
+ def _stage_and_publish(
621
+ dest: Path,
622
+ *,
623
+ workspace_root: Path,
624
+ repo_url: str,
625
+ branch: str,
626
+ reference_base: Path | None,
627
+ ) -> None:
628
+ """Section 8.3: create at a sibling staging path, verify, then rename.
629
+
630
+ A half-verified clone must never be visible at dest_path, because the reuse
631
+ path would later find it and call it healthy -- publication is the only
632
+ moment at which "this clone passed its guards" becomes a fact other code
633
+ can rely on.
634
+
635
+ Staging uses mkdtemp rather than a pid-suffixed name: two concurrent unit
636
+ materializations of the same repository are the normal case under section
637
+ 9.1's parallel clone fan-out, and a name that has to be ARGUED unique is a
638
+ name that eventually is not. git clones happily into an existing empty
639
+ directory, so mkdtemp costs nothing."""
640
+ dest.parent.mkdir(parents=True, exist_ok=True)
641
+ staging = Path(tempfile.mkdtemp(dir=dest.parent, prefix=f".{dest.name}.staging-"))
642
+ try:
643
+ _git_clone(repo_url, staging, branch=branch, reference=reference_base)
644
+ verify_clone_isolation(
645
+ staging,
646
+ workspace_root=workspace_root,
647
+ repo_url=repo_url,
648
+ reference_base=reference_base,
649
+ )
650
+ actual_branch = gitops.current_branch(staging)
651
+ if actual_branch != branch:
652
+ raise CloneExecutionError(
653
+ f"clone of {repo_url} is on branch {actual_branch!r}, not the declared {branch!r}"
654
+ )
655
+ # Publication is INSIDE the cleanup boundary. It was outside, so an
656
+ # OSError from the rename kept dest correctly absent but left a fully
657
+ # populated staging sibling behind -- the no-residue guarantee held for
658
+ # every failure except the one that happens at the publication seam
659
+ # itself (Sentinel, #803 review at bd7afe5). Nothing follows the rename,
660
+ # so on success there is no staging left for the handler to remove.
661
+ os.replace(staging, dest)
662
+ except BaseException as exc:
663
+ try:
664
+ rmtree_or_refuse(staging)
665
+ except IncompleteRemoval as cleanup_exc:
666
+ raise CloneExecutionError(
667
+ f"clone failed ({exc}) and staging cleanup also left it behind: "
668
+ f"{cleanup_exc}"
669
+ ) from exc
670
+ raise
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # grip#807: lane materialization as an independent reference clone.
675
+ #
676
+ # The S4 plan path above clones a declared URL at a declared branch. A LANE has
677
+ # neither a plan nor a URL in hand: it has a source checkout, a destination, and
678
+ # a branch that may exist ONLY in the source (an unpushed seed). It reuses this
679
+ # file's verifier and staging shape rather than growing a second, weaker clone
680
+ # contract inside gitops.py (grip#807 step 7). The one semantic that differs is
681
+ # the publication race: two agents materialize the SAME destination, so the
682
+ # loser must reuse the winner rather than overwrite it. A per-destination O_EXCL
683
+ # lock is the no-replace primitive; the rename verb is not, because os.rename and
684
+ # os.replace both replace an empty target directory on POSIX, so neither refuses
685
+ # a concurrently created dest on its own. The lock plus an absence check taken
686
+ # while it is held is what makes publication no-replace.
687
+ # ---------------------------------------------------------------------------
688
+
689
+
690
+ # Publish-lock waiting bounds. A lane clone publishes in seconds; a loser waits
691
+ # for the winner's dest to appear. Generous enough to cover a slow clone, bounded
692
+ # so a creator that died mid-publish surfaces as an error rather than a hang.
693
+ _LANE_PUBLISH_LOCK_TIMEOUT_S = 120.0
694
+ _LANE_PUBLISH_POLL_S = 0.05
695
+
696
+
697
+ def _lane_clone(repo_url: str, target: Path, *, reference: Path | None) -> None:
698
+ """Clone the canonical URL at its default branch. No ``--branch``: the lane's
699
+ branch (which may be unpushed) is seeded by a one-shot fetch from the source
700
+ afterward, so cloning it from the URL would fail for a branch the URL has
701
+ never seen."""
702
+ command = ["git", "clone", "--quiet"]
703
+ if reference is not None:
704
+ command.extend(["--reference-if-able", str(reference)])
705
+ command.extend([repo_url, str(target)])
706
+ proc = subprocess.run(command, capture_output=True, text=True, check=False)
707
+ if proc.returncode != 0:
708
+ raise CloneExecutionError(
709
+ f"failed to clone lane source {repo_url!r}:\n"
710
+ f"{proc.stderr.strip() or proc.stdout.strip()}"
711
+ )
712
+
713
+
714
+ def _resolve_origin_url(source_repo_root: Path, url: str) -> str:
715
+ """A relative FILESYSTEM origin resolves against the source repo's location,
716
+ which is where git resolves it -- but our clone runs from a staging directory
717
+ in the lane tree, so the same relative string would resolve against the wrong
718
+ cwd and fail (Sentinel, grip#807 v1). A scheme URL (https, ssh, git@) or an
719
+ absolute path is already unambiguous and passes through untouched; a relative
720
+ path is made absolute against the source before it ever reaches a clone with a
721
+ different cwd."""
722
+ if "://" in url or url.startswith("git@") or os.path.isabs(url):
723
+ return url
724
+ # A local relative path (e.g. "../foo.git"): resolve it where git would, at
725
+ # the source repo, not at whatever cwd the clone later runs from.
726
+ return os.path.abspath(os.path.join(source_repo_root, url))
727
+
728
+
729
+ def _publish_lane_atomically(
730
+ staging: Path,
731
+ dest: Path,
732
+ *,
733
+ workspace_root: Path,
734
+ repo_url: str,
735
+ reference_base: Path | None,
736
+ expected_branch: str,
737
+ expected_seed: str | None = None,
738
+ ) -> bool:
739
+ """Publish the staged clone under a per-destination lock (grip#807 step 5).
740
+
741
+ The rename verb is NOT the no-replace primitive: os.rename and os.replace both
742
+ replace an empty target directory on POSIX, so neither refuses a concurrently
743
+ created dest on its own. A per-destination O_EXCL lockfile is the primitive --
744
+ the lock, not the directory, is the claim. The winner holds the lock across
745
+ "dest is absent (checked while holding the lock) -> move staging in"; because
746
+ only a lock holder ever creates dest, no empty dest can appear in that window
747
+ from another lane creator. A loser fails to acquire, waits for the winner's
748
+ dest to appear, discards only its own staging, and reuses the winner. Returns
749
+ True if this creator published, False on reuse.
750
+ """
751
+ lock = dest.parent / f".{dest.name}.publish.lock"
752
+ deadline = time.monotonic() + _LANE_PUBLISH_LOCK_TIMEOUT_S
753
+ while True:
754
+ try:
755
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
756
+ except FileExistsError:
757
+ # Another creator holds the publish lock. Wait for its dest to appear.
758
+ if dest.exists():
759
+ rmtree_or_refuse(staging)
760
+ _reuse_existing_lane(
761
+ dest,
762
+ workspace_root=workspace_root,
763
+ repo_url=repo_url,
764
+ reference_base=reference_base,
765
+ expected_branch=expected_branch,
766
+ expected_seed=expected_seed,
767
+ )
768
+ return False
769
+ if time.monotonic() > deadline:
770
+ raise CloneExecutionError(
771
+ f"timed out after {_LANE_PUBLISH_LOCK_TIMEOUT_S:g}s waiting for the "
772
+ f"publish lock {lock} to release -- a previous creator may have died "
773
+ "mid-publish. Remove the lock file if no publish is in progress"
774
+ )
775
+ time.sleep(_LANE_PUBLISH_POLL_S)
776
+ continue
777
+ try:
778
+ # Lock held. A dest present now is either a prior winner (reuse) or an
779
+ # externally-created directory (reuse validates and refuses it) -- the
780
+ # absence check below is what refuses it, since the rename itself would
781
+ # happily replace an empty directory.
782
+ if dest.exists():
783
+ rmtree_or_refuse(staging)
784
+ _reuse_existing_lane(
785
+ dest,
786
+ workspace_root=workspace_root,
787
+ repo_url=repo_url,
788
+ reference_base=reference_base,
789
+ expected_branch=expected_branch,
790
+ expected_seed=expected_seed,
791
+ )
792
+ return False
793
+ # dest is absent and we hold the lock: no other lane creator can make
794
+ # it, so this move lands on a target proven absent under mutual
795
+ # exclusion. os.rename and os.replace are equivalent here (both replace
796
+ # an empty dir, both fail on a non-empty one) -- the absence check
797
+ # above, not the verb, is the guarantee; os.rename is used only because
798
+ # no overwrite is intended.
799
+ os.rename(staging, dest)
800
+ return True
801
+ finally:
802
+ os.close(fd)
803
+ try:
804
+ os.unlink(lock)
805
+ except FileNotFoundError:
806
+ pass
807
+
808
+
809
+ def _reuse_existing_lane(
810
+ dest: Path,
811
+ *,
812
+ workspace_root: Path,
813
+ repo_url: str,
814
+ reference_base: Path | None,
815
+ expected_branch: str,
816
+ expected_seed: str | None = None,
817
+ ) -> None:
818
+ """grip#807 step 6: a healthy lane on the expected branch is reused untouched.
819
+
820
+ Isolation, origin, and cache are checked first (the same verifier the staging
821
+ path runs), because "is this dirty / on which branch" is not a meaningful
822
+ question about a worktree pointer or a clone of some other repository -- those
823
+ are answered by refusing. A dirty or locally-committed but otherwise valid
824
+ lane on the expected branch is left byte-for-byte: materialization never
825
+ resets, stashes, fetches, or switches."""
826
+ verify_clone_isolation(
827
+ dest,
828
+ workspace_root=workspace_root,
829
+ repo_url=repo_url,
830
+ reference_base=reference_base,
831
+ )
832
+ state = _working_tree_state(dest)
833
+ if state == "unreadable":
834
+ raise CloneExecutionError(
835
+ f"existing lane at {dest} cannot report its working-tree state -- it is "
836
+ "damaged. grip#807 blocks a damaged lane rather than repairing it; blocking "
837
+ "changes none of its bytes"
838
+ )
839
+ actual_head = gitops.current_head_sha(dest)
840
+ if actual_head is None:
841
+ raise CloneExecutionError(
842
+ f"existing lane at {dest} has no resolvable HEAD -- reuse requires a lane "
843
+ "that can be worked in, not merely one whose status command exits 0"
844
+ )
845
+ actual_branch = gitops.current_branch(dest)
846
+ if actual_branch != expected_branch:
847
+ raise CloneExecutionError(
848
+ f"existing lane at {dest} is on branch {actual_branch!r}, not the expected "
849
+ f"{expected_branch!r}. Materialization never switches a lane's branch; move "
850
+ f"it back with `git -C {dest} checkout {expected_branch}` or remove the lane "
851
+ "to re-materialize (grip#807 step 6)"
852
+ )
853
+ if expected_seed is not None and actual_head != expected_seed:
854
+ raise CloneExecutionError(
855
+ f"existing lane at {dest} is at {actual_head!r}, not the requested immutable "
856
+ f"seed {expected_seed!r}. Materialization never resets, fetches, or switches a "
857
+ "reused lane; remove it before reopening at a different review head"
858
+ )
859
+
860
+
861
+ def materialize_lane_clone(
862
+ *,
863
+ source_repo_root: Path,
864
+ dest: Path,
865
+ branch: str,
866
+ seed_commit: str | None = None,
867
+ workspace_root: Path,
868
+ cache_root: Path | None = None,
869
+ ) -> bool:
870
+ """grip#807: materialize a lane repository as an independent reference clone.
871
+
872
+ Returns True on first materialization, False when an existing valid lane is
873
+ reused. Never runs ``git worktree add`` and never accepts a linked worktree
874
+ as a lane checkout -- two lanes on the same branch must not share refs, HEAD,
875
+ reflogs, index, locks, config, or working tree.
876
+ """
877
+ source_repo_root = Path(source_repo_root)
878
+ dest = Path(dest)
879
+ workspace_root = Path(os.fspath(workspace_root))
880
+
881
+ # Step 1: bind the canonical URL from the source's origin and validate it. A
882
+ # relative filesystem origin is resolved against the SOURCE here, because the
883
+ # clone below runs from a staging cwd where the same relative string points
884
+ # elsewhere (Sentinel, grip#807 v1).
885
+ raw_url = gitops.remote_origin_url(source_repo_root)
886
+ if not raw_url:
887
+ raise CloneExecutionError(
888
+ f"lane source {source_repo_root} has no origin remote -- its canonical URL "
889
+ "cannot be derived (grip#807 step 1)"
890
+ )
891
+ repo_url = _resolve_origin_url(source_repo_root, raw_url)
892
+
893
+ # An explicit review pin is an immutable object ID, not a source ref or a
894
+ # local branch spelling. Resolve it before clone/reuse so every following
895
+ # path carries one bound commit. The branch-only lane API keeps its legacy
896
+ # selected-branch-or-HEAD behavior when no pin was supplied.
897
+ if seed_commit is not None:
898
+ if not _SHA40.match(seed_commit):
899
+ raise CloneExecutionError(
900
+ f"explicit lane seed must be a lowercase full 40-hex commit sha, got {seed_commit!r}"
901
+ )
902
+ seed = gitops.git(source_repo_root, "rev-parse", "--verify", f"{seed_commit}^{{commit}}")
903
+ if seed.returncode != 0:
904
+ raise CloneExecutionError(
905
+ f"cannot resolve explicit lane seed {seed_commit!r} in {source_repo_root} to a commit:\n"
906
+ f"{seed.stderr.strip() or seed.stdout.strip()}"
907
+ )
908
+ seed_sha = seed.stdout.strip()
909
+ seed_ref: str | None = None
910
+ else:
911
+ seed_sha = None
912
+ seed_ref = None
913
+
914
+ # The workspace-managed bare cache shares immutable object bytes when present.
915
+ # --reference-if-able degrades silently, so a reference is declared to the
916
+ # verifier ONLY when the cache actually exists; otherwise the verifier would
917
+ # (correctly) reject a clone that declares a reference no alternate records.
918
+ if cache_root is None:
919
+ cache_root = workspace_root / ".grip" / "cache" / "repos" / f"{source_repo_root.name}.git"
920
+ reference_base = cache_root if cache_root.exists() else None
921
+
922
+ if dest.exists():
923
+ _reuse_existing_lane(
924
+ dest,
925
+ workspace_root=workspace_root,
926
+ repo_url=repo_url,
927
+ reference_base=reference_base,
928
+ expected_branch=branch,
929
+ expected_seed=seed_sha,
930
+ )
931
+ return False
932
+
933
+ # Step 2: seed selection binds an IMMUTABLE COMMIT, not a ref name (Atlas,
934
+ # grip#807 v1). An existing source branch seeds at its exact commit; otherwise
935
+ # the source's HEAD commit seeds a new branch. Resolving to a SHA in the source
936
+ # now closes the window in which the source ref could move between selection
937
+ # and fetch, and makes "which commit did this lane start at" answerable.
938
+ if seed_sha is None:
939
+ branch_in_source = (
940
+ gitops.git(source_repo_root, "show-ref", "--verify", f"refs/heads/{branch}").returncode == 0
941
+ )
942
+ seed_ref = f"refs/heads/{branch}" if branch_in_source else "HEAD"
943
+ seed = gitops.git(source_repo_root, "rev-parse", "--verify", f"{seed_ref}^{{commit}}")
944
+ if seed.returncode != 0:
945
+ raise CloneExecutionError(
946
+ f"cannot resolve lane seed {seed_ref!r} in {source_repo_root} to a commit:\n"
947
+ f"{seed.stderr.strip() or seed.stdout.strip()}"
948
+ )
949
+ seed_sha = seed.stdout.strip()
950
+
951
+ dest.parent.mkdir(parents=True, exist_ok=True)
952
+ staging = Path(tempfile.mkdtemp(dir=dest.parent, prefix=f".{dest.name}.staging-"))
953
+ try:
954
+ _lane_clone(repo_url, staging, reference=reference_base)
955
+
956
+ # Step 3: transfer the bound seed COMMIT by a one-shot fetch from the source
957
+ # path (the commit may be unpushed and absent from the origin URL). A path
958
+ # fetch adds no persistent remote. Fetch the exact object; local transport
959
+ # advertises ref tips and the seed is one, so a by-SHA fetch resolves it.
960
+ fetch = gitops.git(staging, "fetch", "--no-tags", str(source_repo_root), seed_sha)
961
+ if fetch.returncode != 0:
962
+ # Some server configs refuse a by-SHA want; fall back to the containing
963
+ # ref, then still check out the bound SHA below.
964
+ if seed_commit is None:
965
+ assert seed_ref is not None
966
+ fetch = gitops.git(staging, "fetch", "--no-tags", str(source_repo_root), seed_ref)
967
+ if fetch.returncode != 0:
968
+ raise CloneExecutionError(
969
+ f"failed to fetch seed {seed_sha} from lane source {source_repo_root}:\n"
970
+ f"{fetch.stderr.strip() or fetch.stdout.strip()}"
971
+ )
972
+ else:
973
+ raise CloneExecutionError(
974
+ f"failed to fetch explicit immutable seed {seed_sha} from lane source {source_repo_root}:\n"
975
+ f"{fetch.stderr.strip() or fetch.stdout.strip()}"
976
+ )
977
+ checkout = gitops.git(staging, "checkout", "-B", branch, seed_sha)
978
+ if checkout.returncode != 0:
979
+ raise CloneExecutionError(
980
+ f"failed to seed lane branch {branch!r} at {seed_sha}:\n"
981
+ f"{checkout.stderr.strip() or checkout.stdout.strip()}"
982
+ )
983
+ # Bind check: HEAD is exactly the commit resolved in the source.
984
+ head_sha = gitops.current_head_sha(staging)
985
+ if head_sha != seed_sha:
986
+ raise CloneExecutionError(
987
+ f"lane seed check failed: staged HEAD {head_sha} is not the bound seed {seed_sha}"
988
+ )
989
+
990
+ # Step 4: prove the isolation invariants on the staged clone before it is
991
+ # ever visible at dest -- .git is a local directory, common-dir resolves
992
+ # inside it, no .git/worktrees, origin is the declared URL, and the only
993
+ # alternate (if any) is the declared cache.
994
+ verify_clone_isolation(
995
+ staging,
996
+ workspace_root=workspace_root,
997
+ repo_url=repo_url,
998
+ reference_base=reference_base,
999
+ )
1000
+
1001
+ # Step 5: publish under a per-destination lock (no-replace primitive).
1002
+ return _publish_lane_atomically(
1003
+ staging,
1004
+ dest,
1005
+ workspace_root=workspace_root,
1006
+ repo_url=repo_url,
1007
+ reference_base=reference_base,
1008
+ expected_branch=branch,
1009
+ expected_seed=seed_sha,
1010
+ )
1011
+ except BaseException as exc:
1012
+ try:
1013
+ rmtree_or_refuse(staging)
1014
+ except IncompleteRemoval as cleanup_exc:
1015
+ raise CloneExecutionError(
1016
+ f"clone failed ({exc}) and staging cleanup also left it behind: "
1017
+ f"{cleanup_exc}"
1018
+ ) from exc
1019
+ raise