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,920 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import fcntl
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from datetime import UTC, datetime
10
+
11
+ from gr2.prototypes import lane_workspace_prototype as lane_proto
12
+
13
+ from .gitops import (
14
+ clone_repo,
15
+ commits_between,
16
+ conflicting_files,
17
+ current_branch,
18
+ current_head_sha,
19
+ discard_if_dirty,
20
+ ensure_lane_checkout,
21
+ ensure_repo_cache,
22
+ fetch_repo,
23
+ is_git_dir,
24
+ is_git_repo,
25
+ repo_dirty,
26
+ stash_if_dirty,
27
+ )
28
+ from .events import EventType, emit, emit_after_outcome
29
+ from .hooks import load_repo_hooks
30
+ from .spec_apply import (
31
+ ValidationIssue,
32
+ _run_materialize_hooks,
33
+ _find_repo,
34
+ _record_apply_state,
35
+ load_workspace_spec_doc,
36
+ repo_cache_path,
37
+ validate_spec,
38
+ workspace_spec_path,
39
+ )
40
+
41
+
42
+ SYNC_ROLLBACK_CONTRACT = (
43
+ "sync preserves completed operations, stops on blocking failure, and reports partial state explicitly; "
44
+ "it does not attempt automatic cross-repo rollback"
45
+ )
46
+ VALID_DIRTY_MODES = {"stash", "block", "discard"}
47
+ SYNC_STRATEGY = "reference-cache"
48
+
49
+
50
+ @dataclasses.dataclass(frozen=True)
51
+ class SyncIssue:
52
+ level: str
53
+ code: str
54
+ scope: str
55
+ subject: str
56
+ message: str
57
+ blocks: bool
58
+ path: str | None = None
59
+ details: dict[str, object] = dataclasses.field(default_factory=dict)
60
+
61
+ def as_dict(self) -> dict[str, object]:
62
+ return dataclasses.asdict(self)
63
+
64
+
65
+ @dataclasses.dataclass(frozen=True)
66
+ class SyncOperation:
67
+ kind: str
68
+ scope: str
69
+ subject: str
70
+ target_path: str
71
+ reason: str
72
+ details: dict[str, object] = dataclasses.field(default_factory=dict)
73
+
74
+ def as_dict(self) -> dict[str, object]:
75
+ return dataclasses.asdict(self)
76
+
77
+
78
+ @dataclasses.dataclass(frozen=True)
79
+ class SyncPlan:
80
+ workspace_root: str
81
+ spec_path: str
82
+ status: str
83
+ dirty_mode: str
84
+ dirty_targets: list[str]
85
+ issues: list[SyncIssue]
86
+ operations: list[SyncOperation]
87
+
88
+ def as_dict(self) -> dict[str, object]:
89
+ return {
90
+ "workspace_root": self.workspace_root,
91
+ "spec_path": self.spec_path,
92
+ "status": self.status,
93
+ "dirty_mode": self.dirty_mode,
94
+ "dirty_targets": list(self.dirty_targets),
95
+ "issue_count": len(self.issues),
96
+ "operation_count": len(self.operations),
97
+ "issues": [item.as_dict() for item in self.issues],
98
+ "operations": [item.as_dict() for item in self.operations],
99
+ }
100
+
101
+
102
+ @dataclasses.dataclass(frozen=True)
103
+ class SyncResult:
104
+ workspace_root: str
105
+ status: str
106
+ plan_status: str
107
+ dirty_mode: str
108
+ dirty_targets: list[str]
109
+ applied: list[str]
110
+ blocked: list[SyncIssue]
111
+ failures: list[SyncIssue]
112
+ rollback_contract: str
113
+ operation_id: str | None = None
114
+
115
+ def as_dict(self) -> dict[str, object]:
116
+ return {
117
+ "workspace_root": self.workspace_root,
118
+ "status": self.status,
119
+ "plan_status": self.plan_status,
120
+ "dirty_mode": self.dirty_mode,
121
+ "dirty_targets": list(self.dirty_targets),
122
+ "applied": list(self.applied),
123
+ "blocked": [item.as_dict() for item in self.blocked],
124
+ "failures": [item.as_dict() for item in self.failures],
125
+ "rollback_contract": self.rollback_contract,
126
+ "operation_id": self.operation_id,
127
+ }
128
+
129
+
130
+ def _spec_issue_to_sync(issue: ValidationIssue) -> SyncIssue:
131
+ return SyncIssue(
132
+ level=issue.level,
133
+ code=issue.code,
134
+ scope="workspace_spec",
135
+ subject=issue.path or "workspace_spec",
136
+ message=issue.message,
137
+ blocks=issue.level == "error",
138
+ path=issue.path,
139
+ )
140
+
141
+
142
+ def _iter_lane_docs(workspace_root: Path) -> list[tuple[str, str, dict[str, object]]]:
143
+ lanes_root = lane_proto.lane_state_root(workspace_root)
144
+ docs: list[tuple[str, str, dict[str, object]]] = []
145
+ if not lanes_root.exists():
146
+ return docs
147
+ for owner_dir in sorted(lanes_root.iterdir()):
148
+ if not owner_dir.is_dir():
149
+ continue
150
+ for lane_dir in sorted(owner_dir.iterdir()):
151
+ lane_toml = lane_dir / "lane.toml"
152
+ if not lane_toml.exists():
153
+ continue
154
+ try:
155
+ doc = lane_proto.load_lane_doc(workspace_root, owner_dir.name, lane_dir.name)
156
+ except Exception as exc: # pragma: no cover - defensive against prototype parser issues
157
+ docs.append(
158
+ (
159
+ owner_dir.name,
160
+ lane_dir.name,
161
+ {
162
+ "lane_name": lane_dir.name,
163
+ "owner_unit": owner_dir.name,
164
+ "_load_error": str(exc),
165
+ },
166
+ )
167
+ )
168
+ continue
169
+ docs.append((owner_dir.name, lane_dir.name, doc))
170
+ return docs
171
+
172
+
173
+ def _status_from_issues(issues: list[SyncIssue]) -> str:
174
+ if any(item.blocks for item in issues):
175
+ return "blocked"
176
+ if issues:
177
+ return "attention"
178
+ return "ready"
179
+
180
+
181
+ def _dirty_targets(issues: list[SyncIssue], operations: list[SyncOperation]) -> list[str]:
182
+ targets: list[str] = []
183
+ for issue in issues:
184
+ if issue.code in {"dirty_shared_repo", "dirty_lane_repo"}:
185
+ targets.append(issue.subject)
186
+ for op in operations:
187
+ if op.kind in {"stash_dirty_repo", "discard_dirty_repo"}:
188
+ targets.append(op.subject)
189
+ return sorted(dict.fromkeys(targets))
190
+
191
+
192
+ def _normalize_dirty_mode(dirty_mode: str) -> str:
193
+ normalized = dirty_mode.strip().lower()
194
+ if normalized not in VALID_DIRTY_MODES:
195
+ raise SystemExit(f"invalid --dirty value '{dirty_mode}'; expected one of: stash, block, discard")
196
+ return normalized
197
+
198
+
199
+ def _operation_id() -> str:
200
+ return os.urandom(8).hex()
201
+
202
+
203
+ def _sync_lock_file(workspace_root: Path) -> Path:
204
+ return workspace_root / ".grip" / "state" / "sync.lock"
205
+
206
+
207
+ def _emit_sync_event(
208
+ workspace_root: Path,
209
+ payload: dict[str, object],
210
+ *,
211
+ after_outcome: bool = False,
212
+ ) -> None:
213
+ event_type = EventType(str(payload.pop("type")))
214
+ payload.pop("workspace", None)
215
+ agent_id = payload.pop("agent_id", None)
216
+ emitter = emit_after_outcome if after_outcome else emit
217
+ emitter(
218
+ event_type=event_type,
219
+ workspace_root=workspace_root,
220
+ actor=str(payload.pop("actor")),
221
+ owner_unit=str(payload.pop("owner_unit")),
222
+ payload=payload,
223
+ agent_id=None if agent_id is None else str(agent_id),
224
+ )
225
+
226
+
227
+ def _sync_context(workspace_root: Path, *, actor: str = "system", owner_unit: str = "workspace") -> dict[str, object]:
228
+ return {
229
+ "workspace": workspace_root.name,
230
+ "actor": actor,
231
+ "owner_unit": owner_unit,
232
+ }
233
+
234
+
235
+ def _plan_repo_names(plan: SyncPlan) -> list[str]:
236
+ repo_names: list[str] = []
237
+ for op in plan.operations:
238
+ if op.scope in {"shared_repo", "lane"}:
239
+ repo_names.append(op.subject.split(":")[-1])
240
+ return sorted(dict.fromkeys(repo_names))
241
+
242
+
243
+ def build_sync_plan(workspace_root: Path, *, dirty_mode: str = "stash") -> SyncPlan:
244
+ workspace_root = workspace_root.resolve()
245
+ dirty_mode = _normalize_dirty_mode(dirty_mode)
246
+ spec_path = workspace_spec_path(workspace_root)
247
+ if not spec_path.exists():
248
+ raise SystemExit(
249
+ f"workspace spec not found: {spec_path}\n"
250
+ "run `gr2 workspace init <path>` first or create .grip/workspace_spec.toml explicitly"
251
+ )
252
+
253
+ issues: list[SyncIssue] = []
254
+ operations: list[SyncOperation] = []
255
+
256
+ issues.extend(_spec_issue_to_sync(issue) for issue in validate_spec(workspace_root))
257
+ if any(item.blocks for item in issues):
258
+ return SyncPlan(
259
+ workspace_root=str(workspace_root),
260
+ spec_path=str(spec_path),
261
+ status=_status_from_issues(issues),
262
+ dirty_mode=dirty_mode,
263
+ dirty_targets=[],
264
+ issues=issues,
265
+ operations=operations,
266
+ )
267
+
268
+ spec = load_workspace_spec_doc(workspace_root)
269
+ for repo in spec.get("repos", []):
270
+ repo_name = str(repo["name"])
271
+ repo_root = workspace_root / str(repo["path"])
272
+ cache_root = repo_cache_path(workspace_root, repo_name)
273
+
274
+ if not cache_root.exists():
275
+ operations.append(
276
+ SyncOperation(
277
+ kind="seed_repo_cache",
278
+ scope="repo_cache",
279
+ subject=repo_name,
280
+ target_path=str(cache_root),
281
+ reason="shared repo cache missing",
282
+ details={"url": str(repo["url"])},
283
+ )
284
+ )
285
+ elif not is_git_dir(cache_root):
286
+ issues.append(
287
+ SyncIssue(
288
+ level="error",
289
+ code="cache_path_conflict",
290
+ scope="repo_cache",
291
+ subject=repo_name,
292
+ message=f"repo cache path exists but is not a bare git dir: {cache_root}",
293
+ blocks=True,
294
+ path=str(cache_root),
295
+ )
296
+ )
297
+ else:
298
+ operations.append(
299
+ SyncOperation(
300
+ kind="refresh_repo_cache",
301
+ scope="repo_cache",
302
+ subject=repo_name,
303
+ target_path=str(cache_root),
304
+ reason="shared repo cache present; refresh remote state",
305
+ details={"url": str(repo["url"])},
306
+ )
307
+ )
308
+
309
+ if not repo_root.exists():
310
+ operations.append(
311
+ SyncOperation(
312
+ kind="clone_shared_repo",
313
+ scope="shared_repo",
314
+ subject=repo_name,
315
+ target_path=str(repo_root),
316
+ reason="shared repo checkout missing",
317
+ details={"url": str(repo["url"])},
318
+ )
319
+ )
320
+ elif not is_git_repo(repo_root):
321
+ issues.append(
322
+ SyncIssue(
323
+ level="error",
324
+ code="shared_repo_path_conflict",
325
+ scope="shared_repo",
326
+ subject=repo_name,
327
+ message=f"shared repo path exists but is not a git repo: {repo_root}",
328
+ blocks=True,
329
+ path=str(repo_root),
330
+ )
331
+ )
332
+ else:
333
+ if repo_dirty(repo_root):
334
+ repo_conflicts = conflicting_files(repo_root)
335
+ if dirty_mode == "block":
336
+ issues.append(
337
+ SyncIssue(
338
+ level="error",
339
+ code="dirty_shared_repo",
340
+ scope="shared_repo",
341
+ subject=repo_name,
342
+ message=f"shared repo has uncommitted changes and blocks sync: {repo_root}",
343
+ blocks=True,
344
+ path=str(repo_root),
345
+ details={"dirty_mode": dirty_mode, "conflicting_files": repo_conflicts},
346
+ )
347
+ )
348
+ else:
349
+ operations.append(
350
+ SyncOperation(
351
+ kind="stash_dirty_repo" if dirty_mode == "stash" else "discard_dirty_repo",
352
+ scope="shared_repo",
353
+ subject=repo_name,
354
+ target_path=str(repo_root),
355
+ reason=f"shared repo is dirty and will be handled via --dirty={dirty_mode}",
356
+ details={"dirty_mode": dirty_mode},
357
+ )
358
+ )
359
+ operations.append(
360
+ SyncOperation(
361
+ kind="fetch_shared_repo",
362
+ scope="shared_repo",
363
+ subject=repo_name,
364
+ target_path=str(repo_root),
365
+ reason="fetch remote refs into existing shared repo checkout",
366
+ )
367
+ )
368
+ hooks = load_repo_hooks(repo_root)
369
+ if hooks:
370
+ operations.append(
371
+ SyncOperation(
372
+ kind="evaluate_repo_hooks",
373
+ scope="shared_repo",
374
+ subject=repo_name,
375
+ target_path=str(repo_root),
376
+ reason="repo hook config present; sync must account for lifecycle/policy rules",
377
+ details={"hook_config": str(repo_root / ".gr2" / "hooks.toml")},
378
+ )
379
+ )
380
+
381
+ for owner_unit, lane_name, lane_doc in _iter_lane_docs(workspace_root):
382
+ if lane_doc.get("_load_error"):
383
+ issues.append(
384
+ SyncIssue(
385
+ level="error",
386
+ code="lane_doc_load_failed",
387
+ scope="lane",
388
+ subject=f"{owner_unit}/{lane_name}",
389
+ message=f"failed to load lane metadata: {lane_doc['_load_error']}",
390
+ blocks=True,
391
+ path=str(lane_proto.lane_file(workspace_root, owner_unit, lane_name)),
392
+ )
393
+ )
394
+ continue
395
+
396
+ lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
397
+ active_leases = [
398
+ lease
399
+ for lease in lane_proto.load_lane_leases(workspace_root, owner_unit, lane_name)
400
+ if not lane_proto.is_stale_lease(lease)
401
+ ]
402
+ if active_leases:
403
+ issues.append(
404
+ SyncIssue(
405
+ level="error",
406
+ code="lease_blocked_sync",
407
+ scope="lane",
408
+ subject=f"{owner_unit}/{lane_name}",
409
+ message=f"lane has active leases that block sync mutation: {owner_unit}/{lane_name}",
410
+ blocks=True,
411
+ path=str(lane_proto.lane_dir(workspace_root, owner_unit, lane_name)),
412
+ details={
413
+ "leases": [
414
+ {"actor": lease["actor"], "mode": lease["mode"], "acquired_at": lease["acquired_at"]}
415
+ for lease in active_leases
416
+ ]
417
+ },
418
+ )
419
+ )
420
+
421
+ for repo_name in lane_doc.get("repos", []):
422
+ lane_repo_root = lane_root / "repos" / str(repo_name)
423
+ expected_branch = str(dict(lane_doc.get("branch_map", {})).get(repo_name, ""))
424
+ if not lane_repo_root.exists():
425
+ operations.append(
426
+ SyncOperation(
427
+ kind="materialize_lane_repo",
428
+ scope="lane",
429
+ subject=f"{owner_unit}/{lane_name}:{repo_name}",
430
+ target_path=str(lane_repo_root),
431
+ reason="lane checkout missing",
432
+ details={"expected_branch": expected_branch},
433
+ )
434
+ )
435
+ continue
436
+ if not is_git_repo(lane_repo_root):
437
+ issues.append(
438
+ SyncIssue(
439
+ level="error",
440
+ code="lane_repo_path_conflict",
441
+ scope="lane",
442
+ subject=f"{owner_unit}/{lane_name}:{repo_name}",
443
+ message=f"lane repo path exists but is not a git repo: {lane_repo_root}",
444
+ blocks=True,
445
+ path=str(lane_repo_root),
446
+ )
447
+ )
448
+ continue
449
+ if repo_dirty(lane_repo_root):
450
+ repo_conflicts = conflicting_files(lane_repo_root)
451
+ if dirty_mode == "block":
452
+ issues.append(
453
+ SyncIssue(
454
+ level="error",
455
+ code="dirty_lane_repo",
456
+ scope="lane",
457
+ subject=f"{owner_unit}/{lane_name}:{repo_name}",
458
+ message=f"lane repo has uncommitted changes and blocks sync: {lane_repo_root}",
459
+ blocks=True,
460
+ path=str(lane_repo_root),
461
+ details={
462
+ "expected_branch": expected_branch,
463
+ "dirty_mode": dirty_mode,
464
+ "conflicting_files": repo_conflicts,
465
+ },
466
+ )
467
+ )
468
+ else:
469
+ operations.append(
470
+ SyncOperation(
471
+ kind="stash_dirty_repo" if dirty_mode == "stash" else "discard_dirty_repo",
472
+ scope="lane",
473
+ subject=f"{owner_unit}/{lane_name}:{repo_name}",
474
+ target_path=str(lane_repo_root),
475
+ reason=f"lane repo is dirty and will be handled via --dirty={dirty_mode}",
476
+ details={"expected_branch": expected_branch, "dirty_mode": dirty_mode},
477
+ )
478
+ )
479
+ operations.append(
480
+ SyncOperation(
481
+ kind="inspect_lane_repo_branch",
482
+ scope="lane",
483
+ subject=f"{owner_unit}/{lane_name}:{repo_name}",
484
+ target_path=str(lane_repo_root),
485
+ reason="lane checkout present; verify branch alignment before any sync run",
486
+ details={"expected_branch": expected_branch},
487
+ )
488
+ )
489
+
490
+ return SyncPlan(
491
+ workspace_root=str(workspace_root),
492
+ spec_path=str(spec_path),
493
+ status=_status_from_issues(issues),
494
+ dirty_mode=dirty_mode,
495
+ dirty_targets=_dirty_targets(issues, operations),
496
+ issues=issues,
497
+ operations=operations,
498
+ )
499
+
500
+
501
+ def render_sync_plan(plan: SyncPlan) -> str:
502
+ lines = [
503
+ "SyncPlan",
504
+ f"workspace_root = {plan.workspace_root}",
505
+ f"status = {plan.status}",
506
+ f"dirty_mode = {plan.dirty_mode}",
507
+ f"issue_count = {len(plan.issues)}",
508
+ f"operation_count = {len(plan.operations)}",
509
+ ]
510
+ if plan.dirty_targets:
511
+ lines.append("DIRTY_TARGETS")
512
+ lines.extend(f"- {item}" for item in plan.dirty_targets)
513
+ if plan.issues:
514
+ lines.append("ISSUES")
515
+ for issue in plan.issues:
516
+ subject = f" [{issue.subject}]" if issue.subject else ""
517
+ lines.append(f"- {issue.level}:{issue.code}{subject} {issue.message}")
518
+ if plan.operations:
519
+ lines.append("OPERATIONS")
520
+ for op in plan.operations:
521
+ lines.append(f"- {op.kind} [{op.scope}] {op.subject} -> {op.target_path} ({op.reason})")
522
+ return "\n".join(lines)
523
+
524
+
525
+ def sync_status_payload(workspace_root: Path) -> dict[str, object]:
526
+ return build_sync_plan(workspace_root).as_dict()
527
+
528
+
529
+ def sync_status_json(workspace_root: Path) -> str:
530
+ return json.dumps(sync_status_payload(workspace_root), indent=2)
531
+
532
+
533
+ def _issue_from_exception(op: SyncOperation, exc: BaseException) -> SyncIssue:
534
+ message = str(exc).strip() or f"sync operation failed: {op.kind}"
535
+ return SyncIssue(
536
+ level="error",
537
+ code=f"{op.kind}_failed",
538
+ scope=op.scope,
539
+ subject=op.subject,
540
+ message=message,
541
+ blocks=True,
542
+ path=op.target_path,
543
+ details={"operation": op.kind},
544
+ )
545
+
546
+
547
+ def _execute_operation(workspace_root: Path, spec: dict[str, object], op: SyncOperation) -> str:
548
+ target_path = Path(op.target_path)
549
+ before_sha = current_head_sha(target_path) if op.scope in {"shared_repo", "lane"} and target_path.exists() else None
550
+ if op.kind in {"seed_repo_cache", "refresh_repo_cache"}:
551
+ repo_spec = _find_repo(spec, op.subject)
552
+ cache_path = repo_cache_path(workspace_root, str(repo_spec["name"]))
553
+ created = ensure_repo_cache(str(repo_spec["url"]), cache_path)
554
+ _emit_sync_event(
555
+ workspace_root,
556
+ {
557
+ "type": "sync.cache_seeded" if created else "sync.cache_refreshed",
558
+ **_sync_context(workspace_root),
559
+ "repo": op.subject,
560
+ "strategy": SYNC_STRATEGY,
561
+ "cache_path": str(cache_path),
562
+ },
563
+ after_outcome=True,
564
+ )
565
+ if op.kind == "seed_repo_cache":
566
+ return f"seeded repo cache for '{op.subject}' at {cache_path}"
567
+ if created:
568
+ return f"seeded repo cache for '{op.subject}' at {cache_path}"
569
+ return f"refreshed repo cache for '{op.subject}' at {cache_path}"
570
+
571
+ if op.kind == "clone_shared_repo":
572
+ repo_spec = _find_repo(spec, op.subject)
573
+ repo_root = workspace_root / str(repo_spec["path"])
574
+ cache_path = repo_cache_path(workspace_root, str(repo_spec["name"]))
575
+ first_materialize = clone_repo(str(repo_spec["url"]), repo_root, reference_repo_root=cache_path)
576
+ _run_materialize_hooks(workspace_root, repo_root, str(repo_spec["name"]), first_materialize, manual_hooks=False)
577
+ after_sha = current_head_sha(repo_root)
578
+ _emit_sync_event(
579
+ workspace_root,
580
+ {
581
+ "type": "sync.repo_updated",
582
+ **_sync_context(workspace_root),
583
+ "repo": op.subject,
584
+ "old_sha": before_sha,
585
+ "new_sha": after_sha,
586
+ "strategy": SYNC_STRATEGY,
587
+ "commits_pulled": commits_between(repo_root, before_sha, after_sha),
588
+ },
589
+ after_outcome=True,
590
+ )
591
+ return f"cloned shared repo '{op.subject}' into {repo_root}"
592
+
593
+ if op.kind == "fetch_shared_repo":
594
+ repo_root = Path(op.target_path)
595
+ branch = current_branch(repo_root) or "main"
596
+ tracking_ref = f"origin/{branch}"
597
+ from .gitops import git as _git_cmd
598
+ proc = _git_cmd(repo_root, "rev-parse", "--verify", tracking_ref)
599
+ old_ref = proc.stdout.strip() if proc.returncode == 0 else None
600
+ fetch_repo(repo_root)
601
+ proc = _git_cmd(repo_root, "rev-parse", "--verify", tracking_ref)
602
+ new_ref = proc.stdout.strip() if proc.returncode == 0 else None
603
+ _emit_sync_event(
604
+ workspace_root,
605
+ {
606
+ "type": "sync.repo_fetched",
607
+ **_sync_context(workspace_root),
608
+ "repo": op.subject,
609
+ "old_ref": old_ref,
610
+ "new_ref": new_ref,
611
+ },
612
+ after_outcome=True,
613
+ )
614
+ return f"fetched remote refs for shared repo '{op.subject}'"
615
+
616
+ if op.kind == "evaluate_repo_hooks":
617
+ repo_root = Path(op.target_path)
618
+ hooks = load_repo_hooks(repo_root)
619
+ if hooks:
620
+ return f"validated repo hooks for '{op.subject}'"
621
+ return f"no repo hooks for '{op.subject}'"
622
+
623
+ if op.kind == "materialize_lane_repo":
624
+ owner_and_lane, repo_name = op.subject.split(":", 1)
625
+ owner_unit, lane_name = owner_and_lane.split("/", 1)
626
+ repo_spec = _find_repo(spec, repo_name)
627
+ source_repo_root = workspace_root / str(repo_spec["path"])
628
+ target_repo_root = Path(op.target_path)
629
+ expected_branch = str(op.details.get("expected_branch", ""))
630
+ first_materialize = ensure_lane_checkout(
631
+ source_repo_root=source_repo_root,
632
+ target_repo_root=target_repo_root,
633
+ branch=expected_branch,
634
+ workspace_root=workspace_root,
635
+ )
636
+ _run_materialize_hooks(workspace_root, target_repo_root, repo_name, first_materialize, manual_hooks=False)
637
+ after_sha = current_head_sha(target_repo_root)
638
+ _emit_sync_event(
639
+ workspace_root,
640
+ {
641
+ "type": "sync.repo_updated",
642
+ **_sync_context(workspace_root, owner_unit=owner_unit),
643
+ "repo": repo_name,
644
+ "old_sha": before_sha,
645
+ "new_sha": after_sha,
646
+ "strategy": SYNC_STRATEGY,
647
+ "commits_pulled": commits_between(target_repo_root, before_sha, after_sha),
648
+ },
649
+ after_outcome=True,
650
+ )
651
+ return f"materialized lane repo '{op.subject}' at {target_repo_root}"
652
+
653
+ if op.kind == "inspect_lane_repo_branch":
654
+ expected_branch = str(op.details.get("expected_branch", "")).strip()
655
+ repo_root = Path(op.target_path)
656
+ actual_branch = current_branch(repo_root)
657
+ if expected_branch and actual_branch != expected_branch:
658
+ raise SystemExit(
659
+ f"lane repo branch mismatch for {op.subject}: expected {expected_branch}, found {actual_branch}"
660
+ )
661
+ return f"verified lane branch for '{op.subject}' ({actual_branch or '-'})"
662
+
663
+ if op.kind == "stash_dirty_repo":
664
+ repo_root = Path(op.target_path)
665
+ if stash_if_dirty(repo_root, f"gr2 sync auto-stash: {op.subject}"):
666
+ _emit_sync_event(
667
+ workspace_root,
668
+ {
669
+ "type": "sync.repo_skipped",
670
+ **_sync_context(workspace_root),
671
+ "repo": op.subject.split(":")[-1],
672
+ "reason": "dirty_stashed",
673
+ },
674
+ after_outcome=True,
675
+ )
676
+ return f"stashed dirty repo state for '{op.subject}'"
677
+ return f"repo already clean for '{op.subject}'"
678
+
679
+ if op.kind == "discard_dirty_repo":
680
+ repo_root = Path(op.target_path)
681
+ if discard_if_dirty(repo_root):
682
+ _emit_sync_event(
683
+ workspace_root,
684
+ {
685
+ "type": "sync.repo_skipped",
686
+ **_sync_context(workspace_root),
687
+ "repo": op.subject.split(":")[-1],
688
+ "reason": "dirty_discarded",
689
+ },
690
+ after_outcome=True,
691
+ )
692
+ return f"discarded dirty repo state for '{op.subject}'"
693
+ return f"repo already clean for '{op.subject}'"
694
+
695
+ raise SystemExit(f"unsupported sync operation kind: {op.kind}")
696
+
697
+
698
+ def _acquire_sync_lock(workspace_root: Path):
699
+ lock_path = _sync_lock_file(workspace_root)
700
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
701
+ lock_fh = lock_path.open("a+", encoding="utf-8")
702
+ try:
703
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
704
+ except BlockingIOError:
705
+ lock_fh.close()
706
+ return None
707
+ return lock_fh
708
+
709
+
710
+ def _release_sync_lock(lock_fh) -> None:
711
+ if lock_fh is None:
712
+ return
713
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
714
+ lock_fh.close()
715
+
716
+
717
+ def run_sync(workspace_root: Path, *, dirty_mode: str = "stash") -> SyncResult:
718
+ workspace_root = workspace_root.resolve()
719
+ dirty_mode = _normalize_dirty_mode(dirty_mode)
720
+ operation_id = _operation_id()
721
+ started_at = time.monotonic()
722
+ lock_fh = _acquire_sync_lock(workspace_root)
723
+ if lock_fh is None:
724
+ blocked_issue = SyncIssue(
725
+ level="error",
726
+ code="sync_lock_held",
727
+ scope="workspace",
728
+ subject=str(workspace_root),
729
+ message="another sync run currently holds the workspace lock",
730
+ blocks=True,
731
+ path=str(_sync_lock_file(workspace_root)),
732
+ details={"operation_id": operation_id},
733
+ )
734
+ _emit_sync_event(
735
+ workspace_root,
736
+ {
737
+ "type": "sync.conflict",
738
+ **_sync_context(workspace_root),
739
+ "reason": "lock_held",
740
+ "repo": workspace_root.name,
741
+ },
742
+ )
743
+ _emit_sync_event(
744
+ workspace_root,
745
+ {
746
+ "type": "sync.completed",
747
+ **_sync_context(workspace_root),
748
+ "status": "blocked",
749
+ "repos_updated": 0,
750
+ "repos_skipped": 0,
751
+ "repos_failed": 1,
752
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
753
+ },
754
+ )
755
+ return SyncResult(
756
+ workspace_root=str(workspace_root),
757
+ status="blocked",
758
+ plan_status="blocked",
759
+ dirty_mode=dirty_mode,
760
+ dirty_targets=[],
761
+ applied=[],
762
+ blocked=[blocked_issue],
763
+ failures=[],
764
+ rollback_contract=SYNC_ROLLBACK_CONTRACT,
765
+ operation_id=operation_id,
766
+ )
767
+
768
+ try:
769
+ _emit_sync_event(
770
+ workspace_root,
771
+ {
772
+ "type": "sync.started",
773
+ **_sync_context(workspace_root),
774
+ "repos": _plan_repo_names(build_sync_plan(workspace_root, dirty_mode=dirty_mode)),
775
+ "strategy": SYNC_STRATEGY,
776
+ },
777
+ )
778
+ except Exception:
779
+ _release_sync_lock(lock_fh)
780
+ raise
781
+ plan = build_sync_plan(workspace_root, dirty_mode=dirty_mode)
782
+ blocked = [issue for issue in plan.issues if issue.blocks]
783
+ if blocked:
784
+ try:
785
+ for issue in blocked:
786
+ if issue.code == "lease_blocked_sync":
787
+ _emit_sync_event(
788
+ workspace_root,
789
+ {
790
+ "type": "sync.conflict",
791
+ **_sync_context(workspace_root, owner_unit=issue.subject.split("/", 1)[0]),
792
+ "reason": "active_lease",
793
+ "repo": issue.subject,
794
+ "conflicting_files": [],
795
+ },
796
+ )
797
+ elif issue.code in {"dirty_shared_repo", "dirty_lane_repo"} and issue.details.get(
798
+ "conflicting_files"
799
+ ):
800
+ repo_name = issue.subject.split(":")[-1]
801
+ _emit_sync_event(
802
+ workspace_root,
803
+ {
804
+ "type": "sync.conflict",
805
+ **_sync_context(
806
+ workspace_root,
807
+ owner_unit=(
808
+ issue.subject.split("/", 1)[0]
809
+ if issue.scope == "lane"
810
+ else "workspace"
811
+ ),
812
+ ),
813
+ "repo": repo_name,
814
+ "conflicting_files": list(issue.details.get("conflicting_files", [])),
815
+ },
816
+ )
817
+ _emit_sync_event(
818
+ workspace_root,
819
+ {
820
+ "type": "sync.completed",
821
+ **_sync_context(workspace_root),
822
+ "status": "blocked",
823
+ "repos_updated": 0,
824
+ "repos_skipped": 0,
825
+ "repos_failed": len(blocked),
826
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
827
+ },
828
+ )
829
+ except Exception:
830
+ _release_sync_lock(lock_fh)
831
+ raise
832
+ _release_sync_lock(lock_fh)
833
+ return SyncResult(
834
+ workspace_root=str(workspace_root),
835
+ status="blocked",
836
+ plan_status=plan.status,
837
+ dirty_mode=dirty_mode,
838
+ dirty_targets=list(plan.dirty_targets),
839
+ applied=[],
840
+ blocked=blocked,
841
+ failures=[],
842
+ rollback_contract=SYNC_ROLLBACK_CONTRACT,
843
+ operation_id=operation_id,
844
+ )
845
+
846
+ spec = load_workspace_spec_doc(workspace_root)
847
+ applied: list[str] = []
848
+ failures: list[SyncIssue] = []
849
+ try:
850
+ for op in plan.operations:
851
+ try:
852
+ applied.append(_execute_operation(workspace_root, spec, op))
853
+ except Exception as exc:
854
+ failures.append(_issue_from_exception(op, exc))
855
+ break
856
+
857
+ if applied:
858
+ _record_apply_state(workspace_root, applied)
859
+
860
+ status = "success"
861
+ if failures and applied:
862
+ status = "partial_failure"
863
+ elif failures:
864
+ status = "failed"
865
+
866
+ _emit_sync_event(
867
+ workspace_root,
868
+ {
869
+ "type": "sync.completed",
870
+ **_sync_context(workspace_root),
871
+ "status": status,
872
+ "repos_updated": sum(1 for op in plan.operations if op.kind in {"clone_shared_repo", "materialize_lane_repo"}),
873
+ "repos_skipped": sum(1 for op in plan.operations if op.kind in {"stash_dirty_repo", "discard_dirty_repo"}),
874
+ "repos_failed": len(failures),
875
+ "duration_ms": int((time.monotonic() - started_at) * 1000),
876
+ },
877
+ after_outcome=True,
878
+ )
879
+
880
+ return SyncResult(
881
+ workspace_root=str(workspace_root),
882
+ status=status,
883
+ plan_status=plan.status,
884
+ dirty_mode=dirty_mode,
885
+ dirty_targets=list(plan.dirty_targets),
886
+ applied=applied,
887
+ blocked=[],
888
+ failures=failures,
889
+ rollback_contract=SYNC_ROLLBACK_CONTRACT,
890
+ operation_id=operation_id,
891
+ )
892
+ finally:
893
+ _release_sync_lock(lock_fh)
894
+
895
+
896
+ def render_sync_result(result: SyncResult) -> str:
897
+ lines = [
898
+ "SyncResult",
899
+ f"workspace_root = {result.workspace_root}",
900
+ f"status = {result.status}",
901
+ f"plan_status = {result.plan_status}",
902
+ f"dirty_mode = {result.dirty_mode}",
903
+ f"operation_id = {result.operation_id or '-'}",
904
+ f"applied_count = {len(result.applied)}",
905
+ f"failure_count = {len(result.failures)}",
906
+ ]
907
+ if result.dirty_targets:
908
+ lines.append("DIRTY_TARGETS")
909
+ lines.extend(f"- {item}" for item in result.dirty_targets)
910
+ if result.applied:
911
+ lines.append("APPLIED")
912
+ lines.extend(f"- {item}" for item in result.applied)
913
+ if result.blocked:
914
+ lines.append("BLOCKED")
915
+ lines.extend(f"- {item.code}: {item.message}" for item in result.blocked)
916
+ if result.failures:
917
+ lines.append("FAILURES")
918
+ lines.extend(f"- {item.code}: {item.message}" for item in result.failures)
919
+ lines.append(f"rollback_contract = {result.rollback_contract}")
920
+ return "\n".join(lines)