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,659 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prototype planner for gr2 repo maintenance policy.
|
|
3
|
+
|
|
4
|
+
This is intentionally separate from gr2 apply. It answers:
|
|
5
|
+
- which repos are missing and need clone/materialization
|
|
6
|
+
- which repos are safe to fast-forward
|
|
7
|
+
- which repos require explicit human intervention
|
|
8
|
+
- where autostash would be required to proceed
|
|
9
|
+
|
|
10
|
+
The goal is to keep workspace structure convergence (`gr2 apply`) separate
|
|
11
|
+
from repo state convergence (`gr2 repo sync` / `gr2 repo pull`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import dataclasses
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import stat
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tomllib
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True)
|
|
29
|
+
class RepoSpec:
|
|
30
|
+
name: str
|
|
31
|
+
path: str
|
|
32
|
+
url: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass(frozen=True)
|
|
36
|
+
class UnitSpec:
|
|
37
|
+
name: str
|
|
38
|
+
path: str
|
|
39
|
+
repos: list[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclasses.dataclass(frozen=True)
|
|
43
|
+
class WorkspaceSpec:
|
|
44
|
+
schema_version: int
|
|
45
|
+
workspace_name: str
|
|
46
|
+
repos: list[RepoSpec]
|
|
47
|
+
units: list[UnitSpec]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass(frozen=True)
|
|
51
|
+
class RepoTarget:
|
|
52
|
+
scope: str
|
|
53
|
+
target_name: str
|
|
54
|
+
repo_name: str
|
|
55
|
+
path: Path
|
|
56
|
+
url: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass(frozen=True)
|
|
60
|
+
class RepoPolicy:
|
|
61
|
+
sync_mode: str
|
|
62
|
+
dirty_policy: str
|
|
63
|
+
tracked_branch: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclasses.dataclass(frozen=True)
|
|
67
|
+
class RepoStatus:
|
|
68
|
+
exists: bool
|
|
69
|
+
is_git_repo: bool
|
|
70
|
+
branch: str | None
|
|
71
|
+
upstream: str | None
|
|
72
|
+
dirty: bool
|
|
73
|
+
ahead: int
|
|
74
|
+
behind: int
|
|
75
|
+
detached: bool
|
|
76
|
+
linked_worktree: bool = False
|
|
77
|
+
git_dir_is_symlink: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclasses.dataclass(frozen=True)
|
|
81
|
+
class PlannedAction:
|
|
82
|
+
target: RepoTarget
|
|
83
|
+
action: str
|
|
84
|
+
reason: str
|
|
85
|
+
status: RepoStatus
|
|
86
|
+
policy: RepoPolicy
|
|
87
|
+
|
|
88
|
+
def as_dict(self) -> dict[str, object]:
|
|
89
|
+
return {
|
|
90
|
+
"scope": self.target.scope,
|
|
91
|
+
"target": self.target.target_name,
|
|
92
|
+
"repo": self.target.repo_name,
|
|
93
|
+
"path": str(self.target.path),
|
|
94
|
+
"action": self.action,
|
|
95
|
+
"reason": self.reason,
|
|
96
|
+
"status": dataclasses.asdict(self.status),
|
|
97
|
+
"policy": dataclasses.asdict(self.policy),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_args() -> argparse.Namespace:
|
|
102
|
+
parser = argparse.ArgumentParser(description="Prototype gr2 repo maintenance planner")
|
|
103
|
+
parser.add_argument("workspace_root", type=Path, help="gr2 workspace root")
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--spec",
|
|
106
|
+
type=Path,
|
|
107
|
+
help="path to workspace_spec.toml (defaults to <workspace>/.grip/workspace_spec.toml)",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--policy",
|
|
111
|
+
type=Path,
|
|
112
|
+
help="optional TOML policy file for branch/sync defaults",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--json",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="emit machine-readable JSON instead of a table",
|
|
118
|
+
)
|
|
119
|
+
return parser.parse_args()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def read_workspace_spec(spec_path: Path) -> WorkspaceSpec:
|
|
123
|
+
with spec_path.open("rb") as fh:
|
|
124
|
+
raw = tomllib.load(fh)
|
|
125
|
+
|
|
126
|
+
repos = [
|
|
127
|
+
RepoSpec(name=item["name"], path=item["path"], url=item["url"])
|
|
128
|
+
for item in raw.get("repos", [])
|
|
129
|
+
]
|
|
130
|
+
units = [
|
|
131
|
+
UnitSpec(
|
|
132
|
+
name=item["name"],
|
|
133
|
+
path=item["path"],
|
|
134
|
+
repos=list(item.get("repos", [])),
|
|
135
|
+
)
|
|
136
|
+
for item in raw.get("units", [])
|
|
137
|
+
]
|
|
138
|
+
return WorkspaceSpec(
|
|
139
|
+
# `workspace init` writes no schema_version, and every other reader
|
|
140
|
+
# uses .get; default to 1 (the only schema shipped) rather than raise.
|
|
141
|
+
schema_version=raw.get("schema_version", 1),
|
|
142
|
+
workspace_name=raw["workspace_name"],
|
|
143
|
+
repos=repos,
|
|
144
|
+
units=units,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_policy(policy_path: Path | None) -> dict[str, object]:
|
|
149
|
+
if policy_path is None:
|
|
150
|
+
return {}
|
|
151
|
+
with policy_path.open("rb") as fh:
|
|
152
|
+
return tomllib.load(fh)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def derive_targets(workspace_root: Path, spec: WorkspaceSpec) -> list[RepoTarget]:
|
|
156
|
+
shared_targets = [
|
|
157
|
+
RepoTarget(
|
|
158
|
+
scope="shared",
|
|
159
|
+
target_name=repo.name,
|
|
160
|
+
repo_name=repo.name,
|
|
161
|
+
path=workspace_root / repo.path,
|
|
162
|
+
url=repo.url,
|
|
163
|
+
)
|
|
164
|
+
for repo in spec.repos
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
repo_map = {repo.name: repo for repo in spec.repos}
|
|
168
|
+
unit_targets: list[RepoTarget] = []
|
|
169
|
+
for unit in spec.units:
|
|
170
|
+
for repo_name in unit.repos:
|
|
171
|
+
repo = repo_map[repo_name]
|
|
172
|
+
unit_targets.append(
|
|
173
|
+
RepoTarget(
|
|
174
|
+
scope="unit",
|
|
175
|
+
target_name=unit.name,
|
|
176
|
+
repo_name=repo_name,
|
|
177
|
+
path=workspace_root / unit.path / repo_name,
|
|
178
|
+
url=repo.url,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return shared_targets + unit_targets
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def policy_for(target: RepoTarget, policy_doc: dict[str, object]) -> RepoPolicy:
|
|
186
|
+
defaults = policy_doc.get("defaults", {})
|
|
187
|
+
repos = policy_doc.get("repos", {})
|
|
188
|
+
|
|
189
|
+
if not isinstance(defaults, dict):
|
|
190
|
+
defaults = {}
|
|
191
|
+
if not isinstance(repos, dict):
|
|
192
|
+
repos = {}
|
|
193
|
+
|
|
194
|
+
repo_overrides = repos.get(target.repo_name, {})
|
|
195
|
+
if not isinstance(repo_overrides, dict):
|
|
196
|
+
repo_overrides = {}
|
|
197
|
+
|
|
198
|
+
if target.scope == "shared":
|
|
199
|
+
sync_mode = str(repo_overrides.get("sync", defaults.get("shared_sync", "ff-only")))
|
|
200
|
+
else:
|
|
201
|
+
sync_mode = str(repo_overrides.get("sync", defaults.get("unit_sync", "explicit")))
|
|
202
|
+
|
|
203
|
+
dirty_policy = str(repo_overrides.get("dirty", defaults.get("dirty", "block")))
|
|
204
|
+
tracked_branch = repo_overrides.get("tracked_branch", defaults.get("tracked_branch"))
|
|
205
|
+
tracked_branch = str(tracked_branch) if tracked_branch is not None else None
|
|
206
|
+
|
|
207
|
+
return RepoPolicy(
|
|
208
|
+
sync_mode=sync_mode,
|
|
209
|
+
dirty_policy=dirty_policy,
|
|
210
|
+
tracked_branch=tracked_branch,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_git(repo_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
215
|
+
return subprocess.run(
|
|
216
|
+
["git", *args],
|
|
217
|
+
cwd=repo_path,
|
|
218
|
+
check=False,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_git_dir_symlink(path: Path) -> bool:
|
|
225
|
+
"""A repo path's ``.git`` IS a symlink into another clone's git state.
|
|
226
|
+
|
|
227
|
+
A different isolation violation from a linked worktree -- the repo does
|
|
228
|
+
not own its own git state either way, but the mechanism and the fix are
|
|
229
|
+
different (remove the symlink and re-clone, vs. convert a worktree).
|
|
230
|
+
Checked FIRST in ``is_linked_worktree``'s caller, same order as
|
|
231
|
+
``clone_exec.py``'s ``verify_clone_isolation``, because a symlink also
|
|
232
|
+
fails the ``not S_ISDIR`` test below and must not double-report as both.
|
|
233
|
+
"""
|
|
234
|
+
git_path = path / ".git"
|
|
235
|
+
try:
|
|
236
|
+
mode = git_path.lstat().st_mode
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
return False
|
|
239
|
+
return stat.S_ISLNK(mode)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def is_linked_worktree(path: Path) -> bool:
|
|
243
|
+
"""A repo path IS a linked worktree (``git worktree add``), not an own
|
|
244
|
+
clone and not a symlinked ``.git`` (see ``is_git_dir_symlink``).
|
|
245
|
+
|
|
246
|
+
Deliberately does not consult ``git rev-parse --is-inside-work-tree`` --
|
|
247
|
+
that answers TRUE inside a linked worktree just as it does inside a real
|
|
248
|
+
clone (the same trap ``clone_exec.py``'s ``verify_clone_isolation``
|
|
249
|
+
docstring names for gr2's lane-clone path), so it can't distinguish the
|
|
250
|
+
two. A plain clone's ``.git`` is a directory; ``git worktree add``
|
|
251
|
+
replaces it with a text file (``gitdir: <path>``). lstat, not
|
|
252
|
+
``Path.is_dir()``, which follows a symlink.
|
|
253
|
+
"""
|
|
254
|
+
git_path = path / ".git"
|
|
255
|
+
try:
|
|
256
|
+
mode = git_path.lstat().st_mode
|
|
257
|
+
except FileNotFoundError:
|
|
258
|
+
return False
|
|
259
|
+
if stat.S_ISLNK(mode):
|
|
260
|
+
return False
|
|
261
|
+
return not stat.S_ISDIR(mode)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ConvertCloneError(Exception):
|
|
265
|
+
"""Raised when a linked-worktree-to-clone conversion is refused."""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _lstat_summary(git_path: Path) -> dict[str, object]:
|
|
269
|
+
try:
|
|
270
|
+
mode = git_path.lstat().st_mode
|
|
271
|
+
except FileNotFoundError:
|
|
272
|
+
return {"exists": False}
|
|
273
|
+
return {
|
|
274
|
+
"exists": True,
|
|
275
|
+
"is_dir": stat.S_ISDIR(mode),
|
|
276
|
+
"is_symlink": stat.S_ISLNK(mode),
|
|
277
|
+
"is_regular_file": stat.S_ISREG(mode),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def convert_worktree_to_clone(path: Path) -> dict[str, object]:
|
|
282
|
+
"""Convert a linked worktree into an own clone, in place.
|
|
283
|
+
|
|
284
|
+
Six steps, proven once by hand against a scratch fixture before this
|
|
285
|
+
function existed:
|
|
286
|
+
|
|
287
|
+
1. Refuse anything that is not GENUINELY a linked worktree -- a symlinked
|
|
288
|
+
``.git`` is a different isolation violation (see
|
|
289
|
+
``is_git_dir_symlink``) with a different fix (remove the symlink and
|
|
290
|
+
re-clone, not convert), and an own clone needs no conversion.
|
|
291
|
+
2. Refuse a dirty tree (tracked or untracked changes) BEFORE any mutation.
|
|
292
|
+
3. Resolve ``--git-common-dir`` to find the canonical repo this worktree
|
|
293
|
+
is linked to.
|
|
294
|
+
4. Clone fresh from the canonical repo over ``file://`` into a staging
|
|
295
|
+
path, and check out the same branch there.
|
|
296
|
+
5. Verify the staging clone's isolation (a real ``.git`` directory, no
|
|
297
|
+
nested ``.git/worktrees``, HEAD matching the original exactly) BEFORE
|
|
298
|
+
touching the original worktree at all.
|
|
299
|
+
6. Only then: ``git worktree remove`` the original (removing it from the
|
|
300
|
+
canonical repo's registry, not merely hiding it), ``git worktree
|
|
301
|
+
prune``, and move the verified staging clone into the original path.
|
|
302
|
+
|
|
303
|
+
Returns a receipt: old common-dir, branch, head sha, and .git lstat
|
|
304
|
+
before and after -- the witness that the conversion actually happened,
|
|
305
|
+
at the same path, not just that no error was raised.
|
|
306
|
+
"""
|
|
307
|
+
path = path.resolve()
|
|
308
|
+
git_path = path / ".git"
|
|
309
|
+
|
|
310
|
+
if is_git_dir_symlink(path):
|
|
311
|
+
raise ConvertCloneError(
|
|
312
|
+
f"{path}: .git is a symlink into another clone's git state, not a "
|
|
313
|
+
"linked worktree -- remove the symlink and re-clone instead"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if not is_linked_worktree(path):
|
|
317
|
+
raise ConvertCloneError(f"{path}: not a linked worktree -- nothing to convert")
|
|
318
|
+
|
|
319
|
+
before = _lstat_summary(git_path)
|
|
320
|
+
|
|
321
|
+
status = run_git(path, "status", "--porcelain")
|
|
322
|
+
if status.stdout.strip():
|
|
323
|
+
raise ConvertCloneError(f"{path}: working tree is dirty, refusing to convert")
|
|
324
|
+
|
|
325
|
+
common_dir_proc = run_git(path, "rev-parse", "--git-common-dir")
|
|
326
|
+
if common_dir_proc.returncode != 0:
|
|
327
|
+
raise ConvertCloneError(
|
|
328
|
+
f"{path}: could not resolve --git-common-dir: {common_dir_proc.stderr.strip()}"
|
|
329
|
+
)
|
|
330
|
+
common_dir = Path(common_dir_proc.stdout.strip())
|
|
331
|
+
if not common_dir.is_absolute():
|
|
332
|
+
common_dir = (path / common_dir).resolve()
|
|
333
|
+
canonical_repo_root = common_dir.parent
|
|
334
|
+
|
|
335
|
+
branch_proc = run_git(path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
336
|
+
branch = branch_proc.stdout.strip()
|
|
337
|
+
if branch_proc.returncode != 0 or branch == "HEAD":
|
|
338
|
+
raise ConvertCloneError(f"{path}: on a detached HEAD, refusing to convert")
|
|
339
|
+
|
|
340
|
+
head_sha = run_git(path, "rev-parse", "HEAD").stdout.strip()
|
|
341
|
+
|
|
342
|
+
staging_path = path.parent / f".convert-staging-{path.name}"
|
|
343
|
+
if staging_path.exists():
|
|
344
|
+
raise ConvertCloneError(f"{staging_path}: staging path already exists")
|
|
345
|
+
|
|
346
|
+
clone_proc = subprocess.run(
|
|
347
|
+
["git", "clone", "-q", f"file://{canonical_repo_root}", str(staging_path)],
|
|
348
|
+
cwd=path.parent,
|
|
349
|
+
capture_output=True,
|
|
350
|
+
text=True,
|
|
351
|
+
)
|
|
352
|
+
if clone_proc.returncode != 0:
|
|
353
|
+
raise ConvertCloneError(
|
|
354
|
+
f"clone from {canonical_repo_root} failed: {clone_proc.stderr.strip()}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
checkout_proc = run_git(staging_path, "checkout", "-q", branch)
|
|
358
|
+
if checkout_proc.returncode != 0:
|
|
359
|
+
shutil.rmtree(staging_path)
|
|
360
|
+
raise ConvertCloneError(
|
|
361
|
+
f"checkout of {branch} in the staging clone failed: {checkout_proc.stderr.strip()}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
staging_git = staging_path / ".git"
|
|
365
|
+
staging_summary = _lstat_summary(staging_git)
|
|
366
|
+
if not staging_summary["is_dir"]:
|
|
367
|
+
shutil.rmtree(staging_path)
|
|
368
|
+
raise ConvertCloneError("staging clone's .git is not a directory -- refusing to proceed")
|
|
369
|
+
if (staging_git / "worktrees").exists():
|
|
370
|
+
shutil.rmtree(staging_path)
|
|
371
|
+
raise ConvertCloneError("staging clone hosts linked worktrees -- refusing to proceed")
|
|
372
|
+
staging_head = run_git(staging_path, "rev-parse", "HEAD").stdout.strip()
|
|
373
|
+
if staging_head != head_sha:
|
|
374
|
+
shutil.rmtree(staging_path)
|
|
375
|
+
raise ConvertCloneError(
|
|
376
|
+
f"staging clone HEAD {staging_head} != original {head_sha} -- refusing to proceed"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
remove_proc = run_git(canonical_repo_root, "worktree", "remove", "--force", str(path))
|
|
380
|
+
if remove_proc.returncode != 0:
|
|
381
|
+
shutil.rmtree(staging_path)
|
|
382
|
+
raise ConvertCloneError(f"git worktree remove failed: {remove_proc.stderr.strip()}")
|
|
383
|
+
run_git(canonical_repo_root, "worktree", "prune")
|
|
384
|
+
|
|
385
|
+
shutil.move(str(staging_path), str(path))
|
|
386
|
+
|
|
387
|
+
after = _lstat_summary(path / ".git")
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
"converted_at": datetime.now(UTC).isoformat(),
|
|
391
|
+
"path": str(path),
|
|
392
|
+
"old_common_dir": str(common_dir),
|
|
393
|
+
"branch": branch,
|
|
394
|
+
"head_sha": head_sha,
|
|
395
|
+
"before_git_lstat": before,
|
|
396
|
+
"after_git_lstat": after,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def inspect_repo(path: Path) -> RepoStatus:
|
|
401
|
+
if not path.exists():
|
|
402
|
+
return RepoStatus(
|
|
403
|
+
exists=False,
|
|
404
|
+
is_git_repo=False,
|
|
405
|
+
branch=None,
|
|
406
|
+
upstream=None,
|
|
407
|
+
dirty=False,
|
|
408
|
+
ahead=0,
|
|
409
|
+
behind=0,
|
|
410
|
+
detached=False,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
git_dir_is_symlink = is_git_dir_symlink(path)
|
|
414
|
+
linked_worktree = is_linked_worktree(path)
|
|
415
|
+
|
|
416
|
+
git_check = run_git(path, "rev-parse", "--is-inside-work-tree")
|
|
417
|
+
if git_check.returncode != 0 or git_check.stdout.strip() != "true":
|
|
418
|
+
return RepoStatus(
|
|
419
|
+
exists=True,
|
|
420
|
+
is_git_repo=False,
|
|
421
|
+
branch=None,
|
|
422
|
+
upstream=None,
|
|
423
|
+
dirty=False,
|
|
424
|
+
ahead=0,
|
|
425
|
+
behind=0,
|
|
426
|
+
detached=False,
|
|
427
|
+
linked_worktree=linked_worktree,
|
|
428
|
+
git_dir_is_symlink=git_dir_is_symlink,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
branch_proc = run_git(path, "symbolic-ref", "--quiet", "--short", "HEAD")
|
|
432
|
+
detached = branch_proc.returncode != 0
|
|
433
|
+
branch = None if detached else branch_proc.stdout.strip()
|
|
434
|
+
|
|
435
|
+
upstream_proc = run_git(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
|
|
436
|
+
upstream = upstream_proc.stdout.strip() if upstream_proc.returncode == 0 else None
|
|
437
|
+
|
|
438
|
+
dirty_proc = run_git(path, "status", "--porcelain")
|
|
439
|
+
dirty = bool(dirty_proc.stdout.strip())
|
|
440
|
+
|
|
441
|
+
ahead = 0
|
|
442
|
+
behind = 0
|
|
443
|
+
if upstream:
|
|
444
|
+
counts_proc = run_git(path, "rev-list", "--left-right", "--count", f"HEAD...{upstream}")
|
|
445
|
+
if counts_proc.returncode == 0:
|
|
446
|
+
left, right = counts_proc.stdout.strip().split()
|
|
447
|
+
ahead = int(left)
|
|
448
|
+
behind = int(right)
|
|
449
|
+
|
|
450
|
+
return RepoStatus(
|
|
451
|
+
exists=True,
|
|
452
|
+
is_git_repo=True,
|
|
453
|
+
branch=branch,
|
|
454
|
+
upstream=upstream,
|
|
455
|
+
dirty=dirty,
|
|
456
|
+
ahead=ahead,
|
|
457
|
+
behind=behind,
|
|
458
|
+
detached=detached,
|
|
459
|
+
linked_worktree=linked_worktree,
|
|
460
|
+
git_dir_is_symlink=git_dir_is_symlink,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def classify(target: RepoTarget, status: RepoStatus, policy: RepoPolicy) -> PlannedAction:
|
|
465
|
+
if not status.exists:
|
|
466
|
+
return PlannedAction(target, "clone_missing", "repo path is absent", status, policy)
|
|
467
|
+
|
|
468
|
+
if not status.is_git_repo:
|
|
469
|
+
return PlannedAction(
|
|
470
|
+
target,
|
|
471
|
+
"block_path_conflict",
|
|
472
|
+
"target path exists but is not a git repo",
|
|
473
|
+
status,
|
|
474
|
+
policy,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if status.git_dir_is_symlink:
|
|
478
|
+
return PlannedAction(
|
|
479
|
+
target,
|
|
480
|
+
"block_git_dir_symlink",
|
|
481
|
+
"repo's .git is a symlink into another clone's git state -- "
|
|
482
|
+
"it does not own its own git state; remove the symlink and "
|
|
483
|
+
"re-clone",
|
|
484
|
+
status,
|
|
485
|
+
policy,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if status.linked_worktree:
|
|
489
|
+
return PlannedAction(
|
|
490
|
+
target,
|
|
491
|
+
"block_linked_worktree",
|
|
492
|
+
"repo path is a linked git worktree, not an own clone -- "
|
|
493
|
+
"gr does not support worktree-backed repos; convert it to a "
|
|
494
|
+
"standalone clone",
|
|
495
|
+
status,
|
|
496
|
+
policy,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if status.detached:
|
|
500
|
+
return PlannedAction(
|
|
501
|
+
target,
|
|
502
|
+
"manual_sync",
|
|
503
|
+
"repo is on a detached HEAD; do not move automatically",
|
|
504
|
+
status,
|
|
505
|
+
policy,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if policy.tracked_branch and status.branch != policy.tracked_branch:
|
|
509
|
+
if target.scope == "shared" and not status.dirty and status.ahead == 0:
|
|
510
|
+
return PlannedAction(
|
|
511
|
+
target,
|
|
512
|
+
"checkout_branch",
|
|
513
|
+
f"shared repo should be on {policy.tracked_branch}, found {status.branch}",
|
|
514
|
+
status,
|
|
515
|
+
policy,
|
|
516
|
+
)
|
|
517
|
+
return PlannedAction(
|
|
518
|
+
target,
|
|
519
|
+
"manual_sync",
|
|
520
|
+
f"branch mismatch: expected {policy.tracked_branch}, found {status.branch}",
|
|
521
|
+
status,
|
|
522
|
+
policy,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
if status.dirty:
|
|
526
|
+
if policy.dirty_policy == "autostash":
|
|
527
|
+
return PlannedAction(
|
|
528
|
+
target,
|
|
529
|
+
"autostash_then_sync",
|
|
530
|
+
"working tree is dirty and policy allows preservation",
|
|
531
|
+
status,
|
|
532
|
+
policy,
|
|
533
|
+
)
|
|
534
|
+
return PlannedAction(
|
|
535
|
+
target,
|
|
536
|
+
"block_dirty",
|
|
537
|
+
"working tree is dirty; stop by default",
|
|
538
|
+
status,
|
|
539
|
+
policy,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if not status.upstream:
|
|
543
|
+
return PlannedAction(
|
|
544
|
+
target,
|
|
545
|
+
"manual_sync",
|
|
546
|
+
"repo has no upstream tracking branch",
|
|
547
|
+
status,
|
|
548
|
+
policy,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if status.behind == 0 and status.ahead == 0:
|
|
552
|
+
return PlannedAction(
|
|
553
|
+
target,
|
|
554
|
+
"no_change",
|
|
555
|
+
"repo is already aligned with upstream",
|
|
556
|
+
status,
|
|
557
|
+
policy,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if status.behind > 0 and status.ahead == 0:
|
|
561
|
+
if policy.sync_mode == "ff-only":
|
|
562
|
+
return PlannedAction(
|
|
563
|
+
target,
|
|
564
|
+
"fast_forward",
|
|
565
|
+
f"repo is behind upstream by {status.behind} commit(s) and can fast-forward",
|
|
566
|
+
status,
|
|
567
|
+
policy,
|
|
568
|
+
)
|
|
569
|
+
return PlannedAction(
|
|
570
|
+
target,
|
|
571
|
+
"manual_sync",
|
|
572
|
+
f"repo is behind upstream by {status.behind} commit(s), but policy requires explicit sync",
|
|
573
|
+
status,
|
|
574
|
+
policy,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if status.behind > 0 and status.ahead > 0:
|
|
578
|
+
return PlannedAction(
|
|
579
|
+
target,
|
|
580
|
+
"manual_sync",
|
|
581
|
+
f"repo diverged from upstream (ahead {status.ahead}, behind {status.behind})",
|
|
582
|
+
status,
|
|
583
|
+
policy,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if status.ahead > 0:
|
|
587
|
+
return PlannedAction(
|
|
588
|
+
target,
|
|
589
|
+
"manual_sync",
|
|
590
|
+
f"repo has {status.ahead} local commit(s) ahead of upstream",
|
|
591
|
+
status,
|
|
592
|
+
policy,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
return PlannedAction(target, "manual_sync", "unclassified repo state", status, policy)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def render_table(actions: list[PlannedAction]) -> str:
|
|
599
|
+
lines = [
|
|
600
|
+
"gr2 repo-maintenance prototype",
|
|
601
|
+
"SCOPE\tTARGET\tREPO\tACTION\tBRANCH\tUPSTREAM\tSTATE\tREASON",
|
|
602
|
+
]
|
|
603
|
+
for item in actions:
|
|
604
|
+
state_bits = []
|
|
605
|
+
if item.status.dirty:
|
|
606
|
+
state_bits.append("dirty")
|
|
607
|
+
if item.status.ahead:
|
|
608
|
+
state_bits.append(f"ahead={item.status.ahead}")
|
|
609
|
+
if item.status.behind:
|
|
610
|
+
state_bits.append(f"behind={item.status.behind}")
|
|
611
|
+
if item.status.detached:
|
|
612
|
+
state_bits.append("detached")
|
|
613
|
+
if item.status.linked_worktree:
|
|
614
|
+
state_bits.append("linked_worktree")
|
|
615
|
+
if item.status.git_dir_is_symlink:
|
|
616
|
+
state_bits.append("git_dir_is_symlink")
|
|
617
|
+
if not state_bits:
|
|
618
|
+
state_bits.append("clean")
|
|
619
|
+
|
|
620
|
+
lines.append(
|
|
621
|
+
"\t".join(
|
|
622
|
+
[
|
|
623
|
+
item.target.scope,
|
|
624
|
+
item.target.target_name,
|
|
625
|
+
item.target.repo_name,
|
|
626
|
+
item.action,
|
|
627
|
+
item.status.branch or "-",
|
|
628
|
+
item.status.upstream or "-",
|
|
629
|
+
",".join(state_bits),
|
|
630
|
+
item.reason,
|
|
631
|
+
]
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
return "\n".join(lines)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def main() -> int:
|
|
638
|
+
args = parse_args()
|
|
639
|
+
workspace_root = args.workspace_root.resolve()
|
|
640
|
+
spec_path = (args.spec or workspace_root / ".grip" / "workspace_spec.toml").resolve()
|
|
641
|
+
spec = read_workspace_spec(spec_path)
|
|
642
|
+
policy_doc = read_policy(args.policy)
|
|
643
|
+
|
|
644
|
+
actions = []
|
|
645
|
+
for target in derive_targets(workspace_root, spec):
|
|
646
|
+
status = inspect_repo(target.path)
|
|
647
|
+
policy = policy_for(target, policy_doc)
|
|
648
|
+
actions.append(classify(target, status, policy))
|
|
649
|
+
|
|
650
|
+
if args.json:
|
|
651
|
+
print(json.dumps([item.as_dict() for item in actions], indent=2))
|
|
652
|
+
else:
|
|
653
|
+
print(render_table(actions))
|
|
654
|
+
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
if __name__ == "__main__":
|
|
659
|
+
sys.exit(main())
|