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
gr2/overlay/trust.py ADDED
@@ -0,0 +1,150 @@
1
+ """Workspace trust model: allowlist-based gating for overlay driver execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from gr2.overlay.types import OverlayRef
13
+
14
+ CURATED_DRIVER_NAMES = frozenset({"overlay-deep", "overlay-prepend", "overlay-union"})
15
+
16
+
17
+ class TrustClass(StrEnum):
18
+ LOCAL = "local"
19
+ TEAM = "team"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class TrustSource:
24
+ kind: str
25
+ pattern: str | None
26
+ signer: str | None
27
+ trust_class: TrustClass
28
+
29
+
30
+ class OverlayTrustError(Exception):
31
+ def __init__(self, message: str, error_code: str) -> None:
32
+ super().__init__(message)
33
+ self.error_code = error_code
34
+
35
+
36
+ def trust_config_path(workspace_root: Path) -> Path:
37
+ return workspace_root / ".grip" / "trust.toml"
38
+
39
+
40
+ def load_workspace_allowlist(workspace_root: Path) -> list[TrustSource]:
41
+ config = trust_config_path(workspace_root)
42
+ if not config.exists():
43
+ return []
44
+
45
+ data = tomllib.loads(config.read_text())
46
+ sources = data.get("source", [])
47
+
48
+ return [
49
+ TrustSource(
50
+ kind=s["kind"],
51
+ pattern=s.get("pattern"),
52
+ signer=s.get("signer"),
53
+ trust_class=TrustClass(s["trust_class"]),
54
+ )
55
+ for s in sources
56
+ ]
57
+
58
+
59
+ def write_workspace_allowlist(workspace_root: Path, sources: list[dict[str, Any]]) -> None:
60
+ config = trust_config_path(workspace_root)
61
+ config.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ if not sources:
64
+ config.write_text("")
65
+ return
66
+
67
+ lines: list[str] = []
68
+ for source in sources:
69
+ lines.append("[[source]]")
70
+ lines.append(f'kind = "{source["kind"]}"')
71
+ if "pattern" in source:
72
+ lines.append(f'pattern = "{source["pattern"]}"')
73
+ if "signer" in source:
74
+ lines.append(f'signer = "{source["signer"]}"')
75
+ lines.append(f'trust_class = "{source["trust_class"]}"')
76
+ lines.append("")
77
+
78
+ config.write_text("\n".join(lines) + "\n")
79
+
80
+
81
+ def authorize_overlay_driver(
82
+ driver_name: str,
83
+ overlay_ref: OverlayRef,
84
+ overlay_source_kind: str,
85
+ overlay_source_value: str | None,
86
+ overlay_signer: str | None,
87
+ allowlist: list[TrustSource],
88
+ declared_driver: str | None = None,
89
+ ) -> TrustClass:
90
+ if driver_name not in CURATED_DRIVER_NAMES:
91
+ raise ValueError(f"Unknown overlay driver: {driver_name}")
92
+
93
+ for source in allowlist:
94
+ if _source_matches(source, overlay_source_kind, overlay_source_value, overlay_signer):
95
+ return source.trust_class
96
+
97
+ if declared_driver is not None:
98
+ raise OverlayTrustError(
99
+ "Overlay source not in allowlist; .gitattributes is metadata, not authority",
100
+ error_code="overlay_untrusted",
101
+ )
102
+
103
+ raise OverlayTrustError(
104
+ "Overlay source not in allowlist",
105
+ error_code="overlay_untrusted",
106
+ )
107
+
108
+
109
+ def _source_matches(
110
+ source: TrustSource,
111
+ overlay_source_kind: str,
112
+ overlay_source_value: str | None,
113
+ overlay_signer: str | None,
114
+ ) -> bool:
115
+ if source.kind != overlay_source_kind:
116
+ return False
117
+
118
+ if source.kind == "path":
119
+ if overlay_source_value is None or source.pattern is None:
120
+ return False
121
+ if ".." in overlay_source_value:
122
+ return False
123
+ return fnmatch.fnmatch(overlay_source_value, source.pattern)
124
+
125
+ if source.kind == "signed":
126
+ if overlay_signer is None or source.signer is None:
127
+ return False
128
+ return overlay_signer == source.signer
129
+
130
+ return False
131
+
132
+
133
+ def can_inspect_overlay(
134
+ overlay_ref: OverlayRef,
135
+ overlay_source_kind: str,
136
+ overlay_source_value: str | None,
137
+ overlay_signer: str | None,
138
+ allowlist: list[TrustSource],
139
+ ) -> bool:
140
+ return True
141
+
142
+
143
+ def can_diff_overlay(
144
+ overlay_ref: OverlayRef,
145
+ overlay_source_kind: str,
146
+ overlay_source_value: str | None,
147
+ overlay_signer: str | None,
148
+ allowlist: list[TrustSource],
149
+ ) -> bool:
150
+ return True
gr2/overlay/types.py ADDED
@@ -0,0 +1,69 @@
1
+ """Data types for the overlay substrate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+
10
+ class OverlayTier(StrEnum):
11
+ A = "config"
12
+ B = "source"
13
+
14
+
15
+ class TrustLevel(StrEnum):
16
+ TRUSTED = "trusted"
17
+ UNTRUSTED = "untrusted"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class OverlayRef:
22
+ """Reference to an overlay stored in refs/overlays/<author>/<name>."""
23
+
24
+ author: str
25
+ name: str
26
+
27
+ @classmethod
28
+ def parse(cls, ref_str: str) -> OverlayRef:
29
+ parts = ref_str.strip().split("/")
30
+ if len(parts) != 2 or not all(parts):
31
+ raise ValueError(f"Invalid overlay ref '{ref_str}': expected '<author>/<name>'")
32
+ return cls(author=parts[0], name=parts[1])
33
+
34
+ @property
35
+ def ref_path(self) -> str:
36
+ return f"refs/overlays/{self.author}/{self.name}"
37
+
38
+
39
+ @dataclass
40
+ class OverlayMeta:
41
+ """Metadata for a captured overlay."""
42
+
43
+ ref: OverlayRef
44
+ tier: OverlayTier
45
+ trust: TrustLevel
46
+ author: str = ""
47
+ signature: str = "unsigned"
48
+ timestamp: str = ""
49
+ parent_overlay_refs: list[str] = field(default_factory=list)
50
+ files: list[str] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class OverlayStackEntry:
55
+ """One entry in the activation stack."""
56
+
57
+ ref: OverlayRef
58
+ priority: int
59
+ active: bool
60
+
61
+
62
+ @dataclass
63
+ class MaterializeResult:
64
+ """Result of eagerly materializing an overlay."""
65
+
66
+ overlay: OverlayRef
67
+ files_written: list[Path]
68
+ conflicts: list[str]
69
+ idempotent: bool
gr2/overlay/units.py ADDED
@@ -0,0 +1,313 @@
1
+ """Unit-of-work manifest: declarative cross-repo overlay activation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from gr2.overlay.cross_repo import (
11
+ RepoOverlayTarget,
12
+ _restore_snapshot,
13
+ activate_overlays_atomically,
14
+ )
15
+ from gr2.overlay.types import OverlayRef
16
+
17
+ _REFS_OVERLAYS_PREFIX = "refs/overlays/"
18
+ _VALID_SCOPES = {"workspace", "repo"}
19
+ _VALID_ON_FAILURE = {"rollback"}
20
+
21
+
22
+ @dataclass
23
+ class RepoUnitSource:
24
+ repo_name: str
25
+ repo_root: Path
26
+ overlay_source_kind: str
27
+ overlay_source_value: str
28
+ overlay_signer: str | None = None
29
+
30
+
31
+ @dataclass
32
+ class UnitOverlaySource:
33
+ repo_name: str
34
+ overlay_ref: OverlayRef
35
+ overlay_source_kind: str
36
+ overlay_source_value: str
37
+ overlay_signer: str | None = None
38
+
39
+
40
+ @dataclass
41
+ class UnitManifest:
42
+ version: int
43
+ scope: str
44
+ source_overlays: list[UnitOverlaySource]
45
+ target_base_ref: str
46
+ depends_on: list[str] = field(default_factory=list)
47
+ on_failure: str = "rollback"
48
+
49
+
50
+ @dataclass
51
+ class UnitApplyPreview:
52
+ status: str
53
+ unit_name: str
54
+ scope: str
55
+ target_base_ref: str
56
+ on_failure: str
57
+ depends_on: list[str]
58
+ repo_order: list[str]
59
+ overlay_refs: list[str]
60
+
61
+
62
+ def unit_manifest_path(workspace_root: Path, name: str) -> Path:
63
+ return workspace_root / ".grip" / "units" / f"{name}.toml"
64
+
65
+
66
+ def load_unit_manifest(workspace_root: Path, name: str) -> UnitManifest:
67
+ path = unit_manifest_path(workspace_root, name)
68
+ raw = tomllib.loads(path.read_text())
69
+
70
+ source_overlays = []
71
+ for entry in raw.get("source_overlays", []):
72
+ ref_str = entry["overlay_ref"]
73
+ if ref_str.startswith(_REFS_OVERLAYS_PREFIX):
74
+ ref_str = ref_str[len(_REFS_OVERLAYS_PREFIX) :]
75
+ overlay_ref = OverlayRef.parse(ref_str)
76
+
77
+ source_overlays.append(
78
+ UnitOverlaySource(
79
+ repo_name=entry["repo_name"],
80
+ overlay_ref=overlay_ref,
81
+ overlay_source_kind=entry["overlay_source_kind"],
82
+ overlay_source_value=entry["overlay_source_value"],
83
+ overlay_signer=entry.get("overlay_signer"),
84
+ )
85
+ )
86
+
87
+ return UnitManifest(
88
+ version=raw["version"],
89
+ scope=raw["scope"],
90
+ source_overlays=source_overlays,
91
+ target_base_ref=raw["target_base_ref"],
92
+ depends_on=raw.get("depends_on", []),
93
+ on_failure=raw["on_failure"],
94
+ )
95
+
96
+
97
+ def validate_unit_manifest(manifest: UnitManifest) -> None:
98
+ if manifest.version != 1:
99
+ raise ValueError(f"Unsupported unit manifest version: {manifest.version}")
100
+ if manifest.scope not in _VALID_SCOPES:
101
+ raise ValueError(f"Invalid scope '{manifest.scope}': must be one of {_VALID_SCOPES}")
102
+ if not manifest.source_overlays:
103
+ raise ValueError("source_overlays must not be empty")
104
+ seen_repos: set[str] = set()
105
+ for source in manifest.source_overlays:
106
+ if source.repo_name in seen_repos:
107
+ raise ValueError(f"Duplicate source overlay repo_name: '{source.repo_name}'")
108
+ seen_repos.add(source.repo_name)
109
+ if not manifest.target_base_ref:
110
+ raise ValueError("target_base_ref must not be empty")
111
+ if manifest.on_failure not in _VALID_ON_FAILURE:
112
+ raise ValueError(
113
+ f"Invalid on_failure '{manifest.on_failure}': must be one of {_VALID_ON_FAILURE}"
114
+ )
115
+
116
+
117
+ def preview_unit_apply(*, workspace_root: Path, unit_name: str) -> UnitApplyPreview:
118
+ manifest = load_unit_manifest(workspace_root, unit_name)
119
+ validate_unit_manifest(manifest)
120
+ return UnitApplyPreview(
121
+ status="ok",
122
+ unit_name=unit_name,
123
+ scope=manifest.scope,
124
+ target_base_ref=manifest.target_base_ref,
125
+ on_failure=manifest.on_failure,
126
+ depends_on=manifest.depends_on,
127
+ repo_order=[s.repo_name for s in manifest.source_overlays],
128
+ overlay_refs=[s.overlay_ref.ref_path for s in manifest.source_overlays],
129
+ )
130
+
131
+
132
+ def apply_unit(
133
+ *, workspace_root: Path, unit_name: str
134
+ ) -> dict[str, object]:
135
+ order = _resolve_dependency_order(workspace_root, unit_name)
136
+ applied: list[str] = []
137
+ for name in order:
138
+ _apply_single_unit(workspace_root=workspace_root, unit_name=name)
139
+ applied.append(name)
140
+ return {"applied_units": applied, "status": "ok"}
141
+
142
+
143
+ def _apply_single_unit(
144
+ *, workspace_root: Path, unit_name: str
145
+ ) -> object:
146
+ manifest = load_unit_manifest(workspace_root, unit_name)
147
+ validate_unit_manifest(manifest)
148
+ targets = _build_targets(workspace_root, manifest)
149
+ return activate_overlays_atomically(targets=targets)
150
+
151
+
152
+ def abort_unit(
153
+ *, workspace_root: Path, unit_name: str
154
+ ) -> dict[str, object]:
155
+ state_path = (
156
+ workspace_root / ".grip" / "unit-transactions" / f"{unit_name}.json"
157
+ )
158
+ state: dict[str, object] = json.loads(state_path.read_text())
159
+ result = rollback_inflight_unit(workspace_root=workspace_root, state=state)
160
+ state_path.unlink()
161
+ return result
162
+
163
+
164
+ def rollback_inflight_unit(
165
+ *, workspace_root: Path, state: dict[str, object]
166
+ ) -> dict[str, object]:
167
+ snapshots: dict[str, dict[str, str]] = state.get("snapshots", {})
168
+ rolled_back: list[str] = []
169
+
170
+ repos_to_rollback = list(state.get("completed_repos", []))
171
+ failing = state.get("failing_repo")
172
+ if failing and failing not in repos_to_rollback:
173
+ repos_to_rollback.append(failing)
174
+
175
+ for repo_name in repos_to_rollback:
176
+ if repo_name in snapshots:
177
+ repo_root = workspace_root / "repos" / repo_name
178
+ _restore_snapshot(repo_root, snapshots[repo_name])
179
+ rolled_back.append(repo_name)
180
+
181
+ return {"status": "rolled_back", "rolled_back_repos": rolled_back}
182
+
183
+
184
+ def propose_unit_manifest(
185
+ *,
186
+ workspace_root: Path,
187
+ unit_name: str,
188
+ scope: str,
189
+ target_base_ref: str,
190
+ source_repos: list[RepoUnitSource],
191
+ depends_on: list[str],
192
+ on_failure: str,
193
+ ) -> UnitManifest:
194
+ source_overlays: list[UnitOverlaySource] = []
195
+
196
+ for repo_src in source_repos:
197
+ stack_path = repo_src.repo_root / ".grip" / "overlay-stack.json"
198
+ if not stack_path.exists():
199
+ raise ValueError(f"'{repo_src.repo_name}' has no active overlay")
200
+
201
+ stack: list[str] = json.loads(stack_path.read_text())
202
+ if not stack:
203
+ raise ValueError(f"'{repo_src.repo_name}' has no active overlay")
204
+ if len(stack) != 1:
205
+ raise ValueError(
206
+ f"'{repo_src.repo_name}' must have exactly one active overlay, "
207
+ f"got {len(stack)}"
208
+ )
209
+
210
+ ref_str = stack[0]
211
+ if ref_str.startswith(_REFS_OVERLAYS_PREFIX):
212
+ ref_str = ref_str[len(_REFS_OVERLAYS_PREFIX) :]
213
+ overlay_ref = OverlayRef.parse(ref_str)
214
+
215
+ source_overlays.append(
216
+ UnitOverlaySource(
217
+ repo_name=repo_src.repo_name,
218
+ overlay_ref=overlay_ref,
219
+ overlay_source_kind=repo_src.overlay_source_kind,
220
+ overlay_source_value=repo_src.overlay_source_value,
221
+ overlay_signer=repo_src.overlay_signer,
222
+ )
223
+ )
224
+
225
+ manifest = UnitManifest(
226
+ version=1,
227
+ scope=scope,
228
+ source_overlays=source_overlays,
229
+ target_base_ref=target_base_ref,
230
+ depends_on=depends_on,
231
+ on_failure=on_failure,
232
+ )
233
+
234
+ path = unit_manifest_path(workspace_root, unit_name)
235
+ new_content = _serialize_manifest(manifest)
236
+
237
+ if path.exists():
238
+ existing_content = path.read_text()
239
+ if existing_content == new_content:
240
+ return manifest
241
+ raise ValueError(
242
+ f"Unit manifest '{unit_name}' already exists with different content. "
243
+ f"Remove the existing manifest before proposing a new one."
244
+ )
245
+
246
+ path.parent.mkdir(parents=True, exist_ok=True)
247
+ path.write_text(new_content)
248
+
249
+ return manifest
250
+
251
+
252
+ def _build_targets(
253
+ workspace_root: Path, manifest: UnitManifest
254
+ ) -> list[RepoOverlayTarget]:
255
+ targets: list[RepoOverlayTarget] = []
256
+ for src in manifest.source_overlays:
257
+ repo_root = workspace_root / "repos" / src.repo_name
258
+ targets.append(
259
+ RepoOverlayTarget(
260
+ repo_name=src.repo_name,
261
+ checkout_root=repo_root,
262
+ overlay_store=repo_root / ".gr2-overlays",
263
+ overlay_ref=src.overlay_ref,
264
+ overlay_source_kind=src.overlay_source_kind,
265
+ overlay_source_value=src.overlay_source_value,
266
+ overlay_signer=src.overlay_signer,
267
+ )
268
+ )
269
+ return targets
270
+
271
+
272
+ def _resolve_dependency_order(workspace_root: Path, unit_name: str) -> list[str]:
273
+ order: list[str] = []
274
+ visited: set[str] = set()
275
+ in_progress: set[str] = set()
276
+
277
+ def visit(name: str) -> None:
278
+ if name in in_progress:
279
+ raise ValueError(f"dependency cycle detected involving '{name}'")
280
+ if name in visited:
281
+ return
282
+ in_progress.add(name)
283
+ manifest = load_unit_manifest(workspace_root, name)
284
+ for dep in manifest.depends_on:
285
+ visit(dep)
286
+ in_progress.remove(name)
287
+ visited.add(name)
288
+ order.append(name)
289
+
290
+ visit(unit_name)
291
+ return order
292
+
293
+
294
+ def _serialize_manifest(manifest: UnitManifest) -> str:
295
+ depends_on_items = ", ".join(f'"{d}"' for d in manifest.depends_on)
296
+ lines = [
297
+ f"version = {manifest.version}",
298
+ f'scope = "{manifest.scope}"',
299
+ f'target_base_ref = "{manifest.target_base_ref}"',
300
+ f'on_failure = "{manifest.on_failure}"',
301
+ f"depends_on = [{depends_on_items}]",
302
+ ]
303
+ for src in manifest.source_overlays:
304
+ lines.append("")
305
+ lines.append("[[source_overlays]]")
306
+ lines.append(f'repo_name = "{src.repo_name}"')
307
+ lines.append(f'overlay_ref = "{src.overlay_ref.ref_path}"')
308
+ lines.append(f'overlay_source_kind = "{src.overlay_source_kind}"')
309
+ lines.append(f'overlay_source_value = "{src.overlay_source_value}"')
310
+ if src.overlay_signer is not None:
311
+ lines.append(f'overlay_signer = "{src.overlay_signer}"')
312
+ lines.append("")
313
+ return "\n".join(lines)
@@ -0,0 +1,59 @@
1
+ """WorkspaceSpec overlays section: declare overlay entries for a workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class OverlaySpecEntry:
12
+ name: str
13
+ path: str
14
+ applies_to: list[str]
15
+ priority: int = 0
16
+
17
+
18
+ def overlay_spec_path(workspace_root: Path) -> Path:
19
+ return workspace_root / ".grip" / "overlays.toml"
20
+
21
+
22
+ def load_overlay_spec(workspace_root: Path) -> list[OverlaySpecEntry]:
23
+ spec_file = overlay_spec_path(workspace_root)
24
+ if not spec_file.exists():
25
+ return []
26
+
27
+ text = spec_file.read_text()
28
+ if not text.strip():
29
+ return []
30
+
31
+ data = tomllib.loads(text)
32
+ raw_entries = data.get("overlays", [])
33
+
34
+ return [
35
+ OverlaySpecEntry(
36
+ name=entry["name"],
37
+ path=entry["path"],
38
+ applies_to=entry["applies_to"],
39
+ priority=entry.get("priority", 0),
40
+ )
41
+ for entry in raw_entries
42
+ ]
43
+
44
+
45
+ def validate_overlay_spec(entries: list[OverlaySpecEntry]) -> None:
46
+ seen_names: set[str] = set()
47
+
48
+ for entry in entries:
49
+ if not entry.name:
50
+ raise ValueError("Overlay entry has empty name")
51
+ if not entry.path:
52
+ raise ValueError("Overlay entry has empty path")
53
+ if not entry.applies_to:
54
+ raise ValueError("Overlay entry has empty applies_to")
55
+ if entry.priority < 0:
56
+ raise ValueError(f"Overlay entry '{entry.name}' has negative priority")
57
+ if entry.name in seen_names:
58
+ raise ValueError(f"Duplicate overlay name: '{entry.name}'")
59
+ seen_names.add(entry.name)
File without changes