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,256 @@
|
|
|
1
|
+
"""Executor for the neutral MaterializationPlan `project_file` operation (S4-C).
|
|
2
|
+
|
|
3
|
+
The operation copies one declared staged input to one declared destination
|
|
4
|
+
inside the unit, and refuses unless the bytes hash to the `source_sha256` the
|
|
5
|
+
plan declares. gr2 reads only the plan: it does not know what the file is for,
|
|
6
|
+
and nothing here depends on its contents.
|
|
7
|
+
|
|
8
|
+
Contract: MaterializationPlan v1 project-file invariants 7 and 8,
|
|
9
|
+
including source confinement and acceptance fruit 14/15/22.
|
|
10
|
+
|
|
11
|
+
INVARIANT 7 IS A TRAP AS LITERALLY WORDED. It says verify the source's
|
|
12
|
+
"reopened bytes match source_sha256". Read word for word -- reopen, hash, then
|
|
13
|
+
copy -- that IS the read-1/read-2 TOCTOU: the bytes hashed and the bytes written
|
|
14
|
+
come from two different reads and nothing binds them, so the conforming-sounding
|
|
15
|
+
implementation is the vulnerable one. `verify_staged_source` therefore RETURNS
|
|
16
|
+
the bytes it hashed, and the caller writes exactly those. One read. Same
|
|
17
|
+
conclusion S4-A reached about consume(): the fix is not to check again, it is to
|
|
18
|
+
stop re-reading.
|
|
19
|
+
|
|
20
|
+
INVARIANT 8 IS SPLIT ACROSS TWO SLICES. It forbids deleting a staged input
|
|
21
|
+
before the receipt carrying its evidence is durable, and names the wrong signal
|
|
22
|
+
outright -- "a successful destination write or in-memory operation result is not
|
|
23
|
+
acknowledgement". That is a purely NEGATIVE constraint, so this slice satisfies
|
|
24
|
+
it by DELETING NOTHING. Receipt-keyed cleanup is its own later slice, because a
|
|
25
|
+
delete driven by a file on disk deserves its own review surface.
|
|
26
|
+
|
|
27
|
+
That carve has a consequence this module must carry: once cleanup has run there
|
|
28
|
+
is no staged input left, so an executor that demanded one would fail on the
|
|
29
|
+
system's own second apply. A destination already hashing to source_sha256 IS
|
|
30
|
+
proof the projection is current, so that case short-circuits before the source
|
|
31
|
+
is touched at all. The short-circuit only reaches reruns -- on a first apply the
|
|
32
|
+
destination does not exist and every source guard runs.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import dataclasses
|
|
37
|
+
import hashlib
|
|
38
|
+
import os
|
|
39
|
+
import stat
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from .spec_apply import (
|
|
43
|
+
MaterializationPlanError,
|
|
44
|
+
ValidatedPlan,
|
|
45
|
+
_read_canonical_workspace_spec_bytes,
|
|
46
|
+
canonicalize_workspace_path,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_STAGING_INPUTS_PARTS = (".grip", "staging", "inputs")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProjectFileExecutionError(MaterializationPlanError):
|
|
53
|
+
"""A validated plan's project_file operation could not be executed safely."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclasses.dataclass(frozen=True)
|
|
57
|
+
class _ProjectFileBinding:
|
|
58
|
+
"""One immutable reading of the operation, taken from A's frozen snapshot
|
|
59
|
+
before any caller-reachable code runs. Same rationale as S4-B's
|
|
60
|
+
_CloneBinding: verification and use must not be separated by anything that
|
|
61
|
+
can run caller code."""
|
|
62
|
+
|
|
63
|
+
source_path: str
|
|
64
|
+
dest_path: str
|
|
65
|
+
source_sha256: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def staging_inputs_root(workspace_root: Path) -> Path:
|
|
69
|
+
return workspace_root.joinpath(*_STAGING_INPUTS_PARTS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _require_workspace_binding(validated: ValidatedPlan, workspace_root: Path) -> None:
|
|
73
|
+
"""Re-prove at USE what validation proved earlier. The plan is bound to a
|
|
74
|
+
WorkspaceSpec at validation while the executor takes workspace_root
|
|
75
|
+
separately, so nothing structurally stops executing a plan against a
|
|
76
|
+
workspace it was never validated for -- every declared relative path would
|
|
77
|
+
resolve elsewhere.
|
|
78
|
+
|
|
79
|
+
Duplicated from clone_exec rather than imported: that one raises
|
|
80
|
+
CloneExecutionError, and a project_file failure reporting a clone error is
|
|
81
|
+
worse than twelve duplicated lines. The shared executor scaffolding wants
|
|
82
|
+
extracting into one module; deferred so it does not land inside a slice PR."""
|
|
83
|
+
try:
|
|
84
|
+
spec_bytes = _read_canonical_workspace_spec_bytes(workspace_root)
|
|
85
|
+
except MaterializationPlanError as exc:
|
|
86
|
+
raise ProjectFileExecutionError(
|
|
87
|
+
f"cannot execute against {workspace_root}: its canonical WorkspaceSpec is "
|
|
88
|
+
f"unreadable ({exc})"
|
|
89
|
+
) from exc
|
|
90
|
+
actual = hashlib.sha256(spec_bytes).hexdigest()
|
|
91
|
+
if actual != validated.workspace_spec_sha256:
|
|
92
|
+
raise ProjectFileExecutionError(
|
|
93
|
+
f"plan is bound to WorkspaceSpec {validated.workspace_spec_sha256} but "
|
|
94
|
+
f"{workspace_root} has {actual} -- executing a plan against a workspace it "
|
|
95
|
+
"was not validated for would resolve every declared path elsewhere"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def verify_staged_source(
|
|
100
|
+
source: Path, *, workspace_root: Path, source_sha256: str
|
|
101
|
+
) -> bytes:
|
|
102
|
+
"""Prove the staged input, and RETURN the bytes that were proven.
|
|
103
|
+
|
|
104
|
+
Returning the buffer is the whole point. A verifier that answers True and
|
|
105
|
+
leaves the caller to reopen the file has verified one read and authorised a
|
|
106
|
+
different one; the bytes that were hashed must be the bytes that get
|
|
107
|
+
written.
|
|
108
|
+
|
|
109
|
+
Two guards this slice's design ASSUMED were its own turned out to be owned
|
|
110
|
+
upstream, and both are deliberately absent rather than duplicated here,
|
|
111
|
+
because an unreachable guard reads as protection while being untestable at
|
|
112
|
+
its own level (S4-A's round-4 conclusion):
|
|
113
|
+
|
|
114
|
+
- Section 383 staging confinement is enforced SYNTACTICALLY by the pinned
|
|
115
|
+
v1 schema at validation. The spec says "syntactically confined", and A
|
|
116
|
+
wired it, so a source_path outside .grip/staging/inputs never produces a
|
|
117
|
+
capability at all.
|
|
118
|
+
- The post-validation symlink is caught by A's canonicalize_workspace_path
|
|
119
|
+
at EXECUTION, because the executor calls it and it lstat-walks every
|
|
120
|
+
component including the last.
|
|
121
|
+
|
|
122
|
+
Both facts are pinned by premise tests, so a loosened schema or canonicalizer
|
|
123
|
+
surfaces there rather than silently opening a hole here.
|
|
124
|
+
|
|
125
|
+
What remains genuinely this guard's own is the input neither upstream check
|
|
126
|
+
sees: a path that exists, is not a symlink, and is not a regular file -- a
|
|
127
|
+
FIFO, socket, or directory sitting where the staged foundation should be."""
|
|
128
|
+
try:
|
|
129
|
+
mode = os.lstat(source).st_mode
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
raise ProjectFileExecutionError(
|
|
132
|
+
f"staged input {source} does not exist -- the staged input is written "
|
|
133
|
+
"foundation before gr2 projects it"
|
|
134
|
+
) from None
|
|
135
|
+
if not stat.S_ISREG(mode):
|
|
136
|
+
kind = "a symlink" if stat.S_ISLNK(mode) else "not a regular file"
|
|
137
|
+
raise ProjectFileExecutionError(
|
|
138
|
+
f"staged input {source} must be a regular file, but it is {kind} -- a "
|
|
139
|
+
"path validated as symlink-free at validation time is unbound at "
|
|
140
|
+
"execution time unless the executor re-proves it"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
data = source.read_bytes()
|
|
144
|
+
actual = hashlib.sha256(data).hexdigest()
|
|
145
|
+
if actual != source_sha256:
|
|
146
|
+
raise ProjectFileExecutionError(
|
|
147
|
+
f"staged input {source} hashes to {actual}, not the declared "
|
|
148
|
+
f"source_sha256 {source_sha256}"
|
|
149
|
+
)
|
|
150
|
+
return data
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _publish_atomically(dest: Path, data: bytes) -> None:
|
|
154
|
+
"""Same-directory temp -> write -> fsync -> atomic replace -> parent fsync.
|
|
155
|
+
|
|
156
|
+
A half-written AGENTS.md is worse than none: Codex reads it on startup, and
|
|
157
|
+
a truncated foundation is a silently wrong one rather than a loud failure."""
|
|
158
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
tmp = dest.parent / f".{dest.name}.tmp-{os.getpid()}"
|
|
160
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o644)
|
|
161
|
+
try:
|
|
162
|
+
with os.fdopen(fd, "wb") as fh:
|
|
163
|
+
fh.write(data)
|
|
164
|
+
fh.flush()
|
|
165
|
+
os.fsync(fh.fileno())
|
|
166
|
+
os.replace(tmp, dest)
|
|
167
|
+
except BaseException:
|
|
168
|
+
tmp.unlink(missing_ok=True)
|
|
169
|
+
raise
|
|
170
|
+
dir_fd = os.open(dest.parent, os.O_RDONLY)
|
|
171
|
+
try:
|
|
172
|
+
os.fsync(dir_fd)
|
|
173
|
+
finally:
|
|
174
|
+
os.close(dir_fd)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _destination_is_current(dest: Path, source_sha256: str) -> bool:
|
|
178
|
+
"""A destination hashing to the declared value IS proof the projection is
|
|
179
|
+
current -- the hash is the contract, and satisfying it needs no source read.
|
|
180
|
+
|
|
181
|
+
This is what makes the cleanup slice safe to exist: once cleanup removes the
|
|
182
|
+
staged input, currency can still be established from the destination alone.
|
|
183
|
+
Without it, C and the cleanup slice would each be correct and together break
|
|
184
|
+
the system's own second apply."""
|
|
185
|
+
try:
|
|
186
|
+
return hashlib.sha256(dest.read_bytes()).hexdigest() == source_sha256
|
|
187
|
+
except (FileNotFoundError, IsADirectoryError, PermissionError):
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def execute_project_file_operation(
|
|
192
|
+
validated: ValidatedPlan, index: int, *, workspace_root: Path
|
|
193
|
+
) -> dict[str, object]:
|
|
194
|
+
"""Execute the `project_file` operation at `index` of a validated plan.
|
|
195
|
+
|
|
196
|
+
Addressed by index rather than filtered by kind so evidence[i] stays aligned
|
|
197
|
+
with operation i, which S4-A's receipt screen requires."""
|
|
198
|
+
# Plain Path before anything else: a caller-supplied Path SUBCLASS can run
|
|
199
|
+
# arbitrary code from its dunders during path work, which is the callback
|
|
200
|
+
# that reopens the check/use window. Normalising before verification removes
|
|
201
|
+
# the surface rather than trying to be safe around it (S4-B round 2).
|
|
202
|
+
workspace_root = Path(os.fspath(workspace_root))
|
|
203
|
+
|
|
204
|
+
validated.verify(require_provenance=True)
|
|
205
|
+
_require_workspace_binding(validated, workspace_root)
|
|
206
|
+
|
|
207
|
+
operations = validated.plan["operations"]
|
|
208
|
+
if not 0 <= index < len(operations):
|
|
209
|
+
raise ProjectFileExecutionError(
|
|
210
|
+
f"operation index {index} is out of range for a plan with "
|
|
211
|
+
f"{len(operations)} operation(s)"
|
|
212
|
+
)
|
|
213
|
+
op = operations[index]
|
|
214
|
+
kind = op.get("kind")
|
|
215
|
+
if kind != "project_file":
|
|
216
|
+
raise ProjectFileExecutionError(
|
|
217
|
+
f"operations[{index}] is kind {kind!r}, not 'project_file' -- its handler "
|
|
218
|
+
"lands with a different slice (clone with S4-B, venv/editable_install "
|
|
219
|
+
"with S4-D)"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
binding = _ProjectFileBinding(
|
|
223
|
+
source_path=str(op["source_path"]),
|
|
224
|
+
dest_path=str(op["dest_path"]),
|
|
225
|
+
source_sha256=str(op["source_sha256"]),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
source = canonicalize_workspace_path(
|
|
229
|
+
workspace_root, binding.source_path, field_name=f"operations[{index}].source_path"
|
|
230
|
+
)
|
|
231
|
+
dest = canonicalize_workspace_path(
|
|
232
|
+
workspace_root, binding.dest_path, field_name=f"operations[{index}].dest_path"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if _destination_is_current(dest, binding.source_sha256):
|
|
236
|
+
written = False
|
|
237
|
+
else:
|
|
238
|
+
data = verify_staged_source(
|
|
239
|
+
source, workspace_root=workspace_root, source_sha256=binding.source_sha256
|
|
240
|
+
)
|
|
241
|
+
_publish_atomically(dest, data)
|
|
242
|
+
written = True
|
|
243
|
+
|
|
244
|
+
# Nothing is deleted here. Invariant 8 forbids removing a staged input
|
|
245
|
+
# before the receipt carrying this evidence is durable, and this executor
|
|
246
|
+
# cannot know when that happens -- so the staged input survives, and the
|
|
247
|
+
# cleanup slice keyed off the published receipt is the only thing entitled
|
|
248
|
+
# to remove it.
|
|
249
|
+
return {
|
|
250
|
+
"kind": "project_file",
|
|
251
|
+
"source_path": binding.source_path,
|
|
252
|
+
"dest_path": binding.dest_path,
|
|
253
|
+
"source_sha256": binding.source_sha256,
|
|
254
|
+
"mode": "copy",
|
|
255
|
+
"written": written,
|
|
256
|
+
}
|
gr2/python_cli/gitops.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
8
|
+
return subprocess.run(
|
|
9
|
+
["git", *args],
|
|
10
|
+
cwd=cwd,
|
|
11
|
+
capture_output=True,
|
|
12
|
+
text=True,
|
|
13
|
+
check=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_git_repo(path: Path) -> bool:
|
|
18
|
+
proc = git(path, "rev-parse", "--is-inside-work-tree")
|
|
19
|
+
return proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def repo_dirty(path: Path) -> bool:
|
|
23
|
+
proc = git(path, "status", "--porcelain")
|
|
24
|
+
return proc.returncode == 0 and bool(proc.stdout.strip())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def remote_origin_url(path: Path) -> str | None:
|
|
28
|
+
proc = git(path, "config", "--get", "remote.origin.url")
|
|
29
|
+
if proc.returncode != 0:
|
|
30
|
+
return None
|
|
31
|
+
value = proc.stdout.strip()
|
|
32
|
+
return value or None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def current_head_sha(path: Path) -> str | None:
|
|
36
|
+
proc = git(path, "rev-parse", "HEAD")
|
|
37
|
+
if proc.returncode != 0:
|
|
38
|
+
return None
|
|
39
|
+
value = proc.stdout.strip()
|
|
40
|
+
return value or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def commits_between(path: Path, old_sha: str | None, new_sha: str | None) -> int:
|
|
44
|
+
if not new_sha:
|
|
45
|
+
return 0
|
|
46
|
+
if not old_sha:
|
|
47
|
+
proc = git(path, "rev-list", "--count", new_sha)
|
|
48
|
+
else:
|
|
49
|
+
proc = git(path, "rev-list", "--count", f"{old_sha}..{new_sha}")
|
|
50
|
+
if proc.returncode != 0:
|
|
51
|
+
return 0
|
|
52
|
+
try:
|
|
53
|
+
return int(proc.stdout.strip() or "0")
|
|
54
|
+
except ValueError:
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def conflicting_files(path: Path) -> list[str]:
|
|
59
|
+
proc = git(path, "diff", "--name-only", "--diff-filter=U")
|
|
60
|
+
if proc.returncode != 0:
|
|
61
|
+
return []
|
|
62
|
+
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ensure_repo_cache(url: str, cache_repo_root: Path) -> bool:
|
|
66
|
+
"""Ensure a local bare mirror exists for a repo URL.
|
|
67
|
+
|
|
68
|
+
Returns True if a cache was created, False if it already existed and was refreshed.
|
|
69
|
+
"""
|
|
70
|
+
if cache_repo_root.exists():
|
|
71
|
+
if not is_git_dir(cache_repo_root):
|
|
72
|
+
raise SystemExit(f"repo cache path exists but is not a git dir: {cache_repo_root}")
|
|
73
|
+
proc = subprocess.run(
|
|
74
|
+
["git", "--git-dir", str(cache_repo_root), "remote", "update", "--prune"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
if proc.returncode != 0:
|
|
80
|
+
raise SystemExit(f"failed to refresh repo cache {cache_repo_root}:\n{proc.stderr or proc.stdout}")
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
cache_repo_root.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
["git", "clone", "--mirror", url, str(cache_repo_root)],
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
check=False,
|
|
89
|
+
)
|
|
90
|
+
if proc.returncode != 0:
|
|
91
|
+
raise SystemExit(f"failed to seed repo cache {url} -> {cache_repo_root}:\n{proc.stderr or proc.stdout}")
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clone_repo(url: str, target_repo_root: Path, *, reference_repo_root: Path | None = None) -> bool:
|
|
96
|
+
if target_repo_root.exists() and is_git_repo(target_repo_root):
|
|
97
|
+
return False
|
|
98
|
+
target_repo_root.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
command = ["git", "clone"]
|
|
100
|
+
if reference_repo_root is not None:
|
|
101
|
+
command.extend(["--reference-if-able", str(reference_repo_root)])
|
|
102
|
+
command.extend([url, str(target_repo_root)])
|
|
103
|
+
proc = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
104
|
+
if proc.returncode != 0:
|
|
105
|
+
raise SystemExit(f"failed to clone {url} -> {target_repo_root}:\n{proc.stderr or proc.stdout}")
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def branch_exists(repo_root: Path, branch: str) -> bool:
|
|
110
|
+
return git(repo_root, "show-ref", "--verify", f"refs/heads/{branch}").returncode == 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fetch_ref(repo_root: Path, remote: str, source_ref: str, local_branch: str) -> None:
|
|
114
|
+
proc = git(repo_root, "fetch", remote, f"{source_ref}:refs/heads/{local_branch}")
|
|
115
|
+
if proc.returncode != 0:
|
|
116
|
+
raise SystemExit(
|
|
117
|
+
f"failed to fetch {source_ref} from {remote} into {local_branch} in {repo_root}:\n{proc.stderr or proc.stdout}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def refresh_existing_branch(repo_root: Path, remote: str, source_ref: str, local_branch: str) -> None:
|
|
122
|
+
proc = git(repo_root, "fetch", remote, source_ref)
|
|
123
|
+
if proc.returncode != 0:
|
|
124
|
+
raise SystemExit(
|
|
125
|
+
f"failed to fetch {source_ref} from {remote} in {repo_root}:\n{proc.stderr or proc.stdout}"
|
|
126
|
+
)
|
|
127
|
+
proc = git(repo_root, "branch", "-f", local_branch, "FETCH_HEAD")
|
|
128
|
+
if proc.returncode != 0:
|
|
129
|
+
raise SystemExit(
|
|
130
|
+
f"failed to refresh existing branch {local_branch} from {source_ref} in {repo_root}:\n{proc.stderr or proc.stdout}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fetch_repo(repo_root: Path, remote: str = "origin") -> None:
|
|
135
|
+
proc = git(repo_root, "fetch", remote)
|
|
136
|
+
if proc.returncode != 0:
|
|
137
|
+
raise SystemExit(
|
|
138
|
+
f"failed to fetch from {remote} in {repo_root}:\n{proc.stderr or proc.stdout}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_git_dir(path: Path) -> bool:
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
["git", "--git-dir", str(path), "rev-parse", "--is-bare-repository"],
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
check=False,
|
|
148
|
+
)
|
|
149
|
+
return proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _derive_workspace_root(source_repo_root: Path) -> Path:
|
|
153
|
+
"""The nearest ancestor holding a ``.grip`` directory is the workspace root;
|
|
154
|
+
used to locate the object cache when a caller does not pass it. Falls back to
|
|
155
|
+
the source's parent so the reference-clone path still runs (with no cache)
|
|
156
|
+
outside a materialized workspace."""
|
|
157
|
+
for candidate in (source_repo_root, *source_repo_root.parents):
|
|
158
|
+
if (candidate / ".grip").is_dir():
|
|
159
|
+
return candidate
|
|
160
|
+
return source_repo_root.parent
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def ensure_lane_checkout(
|
|
164
|
+
*,
|
|
165
|
+
source_repo_root: Path,
|
|
166
|
+
target_repo_root: Path,
|
|
167
|
+
branch: str,
|
|
168
|
+
workspace_root: Path | None = None,
|
|
169
|
+
seed_commit: str | None = None,
|
|
170
|
+
) -> bool:
|
|
171
|
+
"""Ensure a real, state-isolated lane checkout exists (grip#807).
|
|
172
|
+
|
|
173
|
+
The lane is materialized as an INDEPENDENT reference clone, never a
|
|
174
|
+
``git worktree add``: two lanes checking out the same branch must not share
|
|
175
|
+
refs, HEAD, reflogs, index, locks, config, or working-tree state. The clone
|
|
176
|
+
contract lives in the single clone_exec seam; this is the thin lane entry to
|
|
177
|
+
it. Returns True on first materialization, False when a valid lane is reused.
|
|
178
|
+
"""
|
|
179
|
+
# Lazy import: clone_exec imports gitops, so a module-level import here would
|
|
180
|
+
# be a cycle.
|
|
181
|
+
from . import clone_exec
|
|
182
|
+
|
|
183
|
+
if workspace_root is None:
|
|
184
|
+
workspace_root = _derive_workspace_root(source_repo_root)
|
|
185
|
+
|
|
186
|
+
return clone_exec.materialize_lane_clone(
|
|
187
|
+
source_repo_root=source_repo_root,
|
|
188
|
+
dest=target_repo_root,
|
|
189
|
+
branch=branch,
|
|
190
|
+
seed_commit=seed_commit,
|
|
191
|
+
workspace_root=workspace_root,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def checkout_branch(repo_root: Path, branch: str) -> None:
|
|
196
|
+
proc = git(repo_root, "checkout", branch)
|
|
197
|
+
if proc.returncode != 0:
|
|
198
|
+
raise SystemExit(f"failed to checkout {branch} in {repo_root}:\n{proc.stderr or proc.stdout}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def current_branch(repo_root: Path) -> str:
|
|
202
|
+
proc = git(repo_root, "branch", "--show-current")
|
|
203
|
+
if proc.returncode != 0:
|
|
204
|
+
raise SystemExit(f"failed to determine current branch in {repo_root}:\n{proc.stderr or proc.stdout}")
|
|
205
|
+
return proc.stdout.strip()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def stash_if_dirty(repo_root: Path, message: str) -> bool:
|
|
209
|
+
if not repo_dirty(repo_root):
|
|
210
|
+
return False
|
|
211
|
+
proc = git(repo_root, "stash", "push", "-u", "-m", message)
|
|
212
|
+
if proc.returncode != 0:
|
|
213
|
+
raise SystemExit(f"failed to stash dirty work in {repo_root}:\n{proc.stderr or proc.stdout}")
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def discard_if_dirty(repo_root: Path) -> bool:
|
|
218
|
+
if not repo_dirty(repo_root):
|
|
219
|
+
return False
|
|
220
|
+
proc = git(repo_root, "reset", "--hard", "HEAD")
|
|
221
|
+
if proc.returncode != 0:
|
|
222
|
+
raise SystemExit(f"failed to discard tracked changes in {repo_root}:\n{proc.stderr or proc.stdout}")
|
|
223
|
+
proc = git(repo_root, "clean", "-fd")
|
|
224
|
+
if proc.returncode != 0:
|
|
225
|
+
raise SystemExit(f"failed to discard untracked changes in {repo_root}:\n{proc.stderr or proc.stdout}")
|
|
226
|
+
return True
|