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,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
+ }
@@ -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