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,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())