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