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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- 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
|