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