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/python_cli/commit.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Native commit creation for the Python gr2 CLI.
|
|
2
|
+
|
|
3
|
+
Single-repository by default; lane-aware across a materialized lane's repos
|
|
4
|
+
when a workspace + owner unit are supplied (impedance B).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from gr2.prototypes import lane_workspace_prototype as lane_proto
|
|
13
|
+
|
|
14
|
+
from .gitops import git
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommitError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NothingToCommitError(CommitError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CommitReceipt:
|
|
27
|
+
commit_sha: str
|
|
28
|
+
message: str
|
|
29
|
+
amended: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _head_sha(repo: Path, *, required: bool) -> str | None:
|
|
33
|
+
try:
|
|
34
|
+
result = git(repo, "rev-parse", "--verify", "HEAD")
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
raise CommitError(f"failed to resolve HEAD in {repo}: {exc}") from exc
|
|
37
|
+
sha = result.stdout.strip() if result.returncode == 0 else ""
|
|
38
|
+
if required and not sha:
|
|
39
|
+
raise CommitError("git commit succeeded but the resulting HEAD could not be verified")
|
|
40
|
+
return sha or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _staged_changes_exist(repo: Path) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
probe = git(repo, "diff", "--cached", "--quiet", "--exit-code")
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise CommitError(f"failed to inspect the staged index in {repo}: {exc}") from exc
|
|
48
|
+
if probe.returncode == 0:
|
|
49
|
+
return False
|
|
50
|
+
if probe.returncode == 1:
|
|
51
|
+
return True
|
|
52
|
+
detail = (probe.stderr or probe.stdout).strip()
|
|
53
|
+
raise CommitError(detail or f"staged-index probe failed with exit {probe.returncode}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _refuse_review_ephemeral_repo(repo: Path) -> None:
|
|
57
|
+
"""A review-ephemeral lane repo carries a review record naming its kind. Refuse
|
|
58
|
+
a commit into it directly (the reviewer's cwd is inside the review lane), so a
|
|
59
|
+
review lane never becomes a work lane through the single-repo path."""
|
|
60
|
+
import json as _json
|
|
61
|
+
record = Path(repo) / ".git" / "grip-review.json"
|
|
62
|
+
try:
|
|
63
|
+
kind = _json.loads(record.read_text()).get("lane_kind")
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
return
|
|
66
|
+
if kind == "review-ephemeral":
|
|
67
|
+
raise CommitError(
|
|
68
|
+
f"{repo} is a review-ephemeral review lane (read-only, disposable): it "
|
|
69
|
+
"cannot be committed to. A review lane never becomes a work lane."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_commit(repo: Path, message: str, *, amend: bool = False) -> CommitReceipt:
|
|
74
|
+
"""Create a commit and return the immutable commit ID observed afterward.
|
|
75
|
+
|
|
76
|
+
A normal commit first asks the index whether a staged diff exists. It never
|
|
77
|
+
classifies "nothing to commit" by localized or version-dependent prose.
|
|
78
|
+
Amend is allowed without a new staged diff because changing only the prior
|
|
79
|
+
commit message is a valid amend operation.
|
|
80
|
+
"""
|
|
81
|
+
if not message:
|
|
82
|
+
raise CommitError("commit message must not be empty")
|
|
83
|
+
_refuse_review_ephemeral_repo(repo)
|
|
84
|
+
if not amend and not _staged_changes_exist(repo):
|
|
85
|
+
raise NothingToCommitError("no staged changes to commit")
|
|
86
|
+
|
|
87
|
+
head_before = _head_sha(repo, required=False)
|
|
88
|
+
|
|
89
|
+
args = ["commit"]
|
|
90
|
+
if amend:
|
|
91
|
+
args.append("--amend")
|
|
92
|
+
args.extend(["-m", message])
|
|
93
|
+
try:
|
|
94
|
+
committed = git(repo, *args)
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
raise CommitError(f"failed to launch git commit in {repo}: {exc}") from exc
|
|
97
|
+
if committed.returncode != 0:
|
|
98
|
+
detail = (committed.stderr or committed.stdout).strip()
|
|
99
|
+
raise CommitError(detail or f"git commit failed with exit {committed.returncode}")
|
|
100
|
+
|
|
101
|
+
commit_sha = _head_sha(repo, required=True)
|
|
102
|
+
assert commit_sha is not None
|
|
103
|
+
if commit_sha == head_before:
|
|
104
|
+
raise CommitError(
|
|
105
|
+
f"git commit returned success but HEAD did not advance from {head_before}"
|
|
106
|
+
)
|
|
107
|
+
return CommitReceipt(commit_sha=commit_sha, message=message, amended=amend)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- lane-aware commit (impedance B) --------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class LaneRepoCommit:
|
|
115
|
+
repo: str
|
|
116
|
+
status: str # "committed" | "skipped_empty" | "failed"
|
|
117
|
+
commit_sha: str | None = None
|
|
118
|
+
error: str | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class LaneCommitReport:
|
|
123
|
+
owner_unit: str
|
|
124
|
+
lane_name: str
|
|
125
|
+
lane_kind: str
|
|
126
|
+
results: list[LaneRepoCommit] = field(default_factory=list)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def any_committed(self) -> bool:
|
|
130
|
+
return any(r.status == "committed" for r in self.results)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def any_failed(self) -> bool:
|
|
134
|
+
return any(r.status == "failed" for r in self.results)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _lane_repo_targets(
|
|
138
|
+
workspace_root: Path, owner_unit: str, lane_name: str, doc: dict
|
|
139
|
+
) -> list[tuple[str, Path]]:
|
|
140
|
+
"""(repo_key, repo_root) for each repo the lane commit should touch.
|
|
141
|
+
|
|
142
|
+
A bound lane is single-repo: its one repo lives in the author's bound
|
|
143
|
+
worktree, not under the lane state tree. A materialized lane commits each
|
|
144
|
+
of its repos/<key> checkouts.
|
|
145
|
+
"""
|
|
146
|
+
kind = doc.get("lane_kind", "materialized")
|
|
147
|
+
repos = list(doc.get("repos", []))
|
|
148
|
+
if kind == "bound":
|
|
149
|
+
worktree = doc.get("bound_worktree")
|
|
150
|
+
if not worktree:
|
|
151
|
+
raise CommitError(
|
|
152
|
+
f"bound lane {owner_unit}/{lane_name} records no bound_worktree"
|
|
153
|
+
)
|
|
154
|
+
key = repos[0] if repos else "bound"
|
|
155
|
+
return [(key, Path(worktree))]
|
|
156
|
+
lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
|
|
157
|
+
return [(repo, lane_root / "repos" / repo) for repo in repos]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def commit_lane(
|
|
161
|
+
workspace_root: Path,
|
|
162
|
+
owner_unit: str,
|
|
163
|
+
message: str,
|
|
164
|
+
*,
|
|
165
|
+
lane_name: str | None = None,
|
|
166
|
+
amend: bool = False,
|
|
167
|
+
) -> LaneCommitReport:
|
|
168
|
+
"""Commit each repo of a lane under one message.
|
|
169
|
+
|
|
170
|
+
Repos with an empty staged index are skipped and reported (never an empty
|
|
171
|
+
commit). A repo whose commit fails is recorded as failed and does NOT roll
|
|
172
|
+
back the repos already committed; the report names it so the caller can act.
|
|
173
|
+
Bound lanes stay single-repo (the bound worktree).
|
|
174
|
+
"""
|
|
175
|
+
if not message:
|
|
176
|
+
raise CommitError("commit message must not be empty")
|
|
177
|
+
workspace_root = Path(workspace_root).resolve()
|
|
178
|
+
if lane_name is None:
|
|
179
|
+
lane_name = lane_proto.require_current_lane(workspace_root, owner_unit)["lane_name"]
|
|
180
|
+
doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
|
|
181
|
+
kind = doc.get("lane_kind", "materialized")
|
|
182
|
+
if kind == "review-ephemeral":
|
|
183
|
+
raise CommitError(
|
|
184
|
+
f"lane {owner_unit}/{lane_name} is a review-ephemeral lane (read-only, "
|
|
185
|
+
"disposable): it cannot be committed to. A review lane never becomes a "
|
|
186
|
+
"work lane; exit the review and open a work lane to make changes."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
results: list[LaneRepoCommit] = []
|
|
190
|
+
for repo_key, repo_root in _lane_repo_targets(workspace_root, owner_unit, lane_name, doc):
|
|
191
|
+
try:
|
|
192
|
+
if not amend and not _staged_changes_exist(repo_root):
|
|
193
|
+
results.append(LaneRepoCommit(repo_key, "skipped_empty"))
|
|
194
|
+
continue
|
|
195
|
+
receipt = create_commit(repo_root, message, amend=amend)
|
|
196
|
+
results.append(LaneRepoCommit(repo_key, "committed", commit_sha=receipt.commit_sha))
|
|
197
|
+
except CommitError as exc:
|
|
198
|
+
results.append(LaneRepoCommit(repo_key, "failed", error=str(exc)))
|
|
199
|
+
return LaneCommitReport(owner_unit=owner_unit, lane_name=lane_name, lane_kind=kind, results=results)
|
gr2/python_cli/config.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Config base + overlay model: Docker OverlayFS analogy for workspace config.
|
|
2
|
+
|
|
3
|
+
Base layer: TOML files (cold, human-authored, git-reviewed)
|
|
4
|
+
Overlay layer: JSON files (hot, agent-writable)
|
|
5
|
+
Reads: overlay first, base fallback
|
|
6
|
+
Writes: overlay only
|
|
7
|
+
_base_sha: overlay tracks which base version it extends
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from copy import deepcopy
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import toml
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Exceptions
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseStaleError(Exception):
|
|
27
|
+
"""Raised when overlay's _base_sha doesn't match current base content."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PolicyViolationError(Exception):
|
|
31
|
+
"""Raised when a write is blocked by the active policy."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Policies
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WritePolicy(ABC):
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def can_write(self, agent: str, section: str, key: str) -> bool: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FreeWritePolicy(WritePolicy):
|
|
45
|
+
def can_write(self, agent: str, section: str, key: str) -> bool:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OwnWriteOnlyPolicy(WritePolicy):
|
|
50
|
+
"""Agents can only write to their own section (agents.<name>, prompts.<name>).
|
|
51
|
+
|
|
52
|
+
Shared sections (spawn, tools, etc.) are writable by anyone.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_SCOPED_PREFIXES = ("agents.", "prompts.")
|
|
56
|
+
|
|
57
|
+
def can_write(self, agent: str, section: str, key: str) -> bool:
|
|
58
|
+
for prefix in self._SCOPED_PREFIXES:
|
|
59
|
+
if section.startswith(prefix):
|
|
60
|
+
section_owner = section[len(prefix) :].split(".")[0]
|
|
61
|
+
return section_owner == agent
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Helpers
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _base_sha(content: str) -> str:
|
|
71
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _deep_merge(base: dict, overlay: dict) -> dict:
|
|
75
|
+
"""Recursively merge overlay into base. Overlay wins on conflict."""
|
|
76
|
+
result = deepcopy(base)
|
|
77
|
+
for key, val in overlay.items():
|
|
78
|
+
if key == "_base_sha":
|
|
79
|
+
continue
|
|
80
|
+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
|
|
81
|
+
result[key] = _deep_merge(result[key], val)
|
|
82
|
+
else:
|
|
83
|
+
result[key] = deepcopy(val)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolve_dotted_key(data: dict, key: str) -> Any:
|
|
88
|
+
parts = key.split(".")
|
|
89
|
+
current: Any = data
|
|
90
|
+
for part in parts:
|
|
91
|
+
if not isinstance(current, dict) or part not in current:
|
|
92
|
+
return None
|
|
93
|
+
current = current[part]
|
|
94
|
+
return current
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _set_nested(data: dict, dotted_section: str, key: str, value: Any) -> None:
|
|
98
|
+
parts = dotted_section.split(".")
|
|
99
|
+
current = data
|
|
100
|
+
for part in parts:
|
|
101
|
+
if part not in current or not isinstance(current[part], dict):
|
|
102
|
+
current[part] = {}
|
|
103
|
+
current = current[part]
|
|
104
|
+
current[key] = value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _overlay_stem(base_path: Path) -> str:
|
|
108
|
+
return base_path.stem
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Public API
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def config_apply(base_path: Path, overlay_dir: Path) -> dict:
|
|
117
|
+
"""Materialize TOML base into JSON overlay with _base_sha.
|
|
118
|
+
|
|
119
|
+
If overlay already exists, merges new base keys into it while
|
|
120
|
+
preserving existing overlay modifications.
|
|
121
|
+
"""
|
|
122
|
+
base_content = base_path.read_text()
|
|
123
|
+
base_data = toml.loads(base_content)
|
|
124
|
+
sha = _base_sha(base_content)
|
|
125
|
+
|
|
126
|
+
overlay_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
overlay_path = overlay_dir / f"{_overlay_stem(base_path)}.json"
|
|
128
|
+
|
|
129
|
+
if overlay_path.exists():
|
|
130
|
+
existing = json.loads(overlay_path.read_text())
|
|
131
|
+
existing.pop("_base_sha", None)
|
|
132
|
+
merged = _deep_merge(base_data, existing)
|
|
133
|
+
else:
|
|
134
|
+
merged = deepcopy(base_data)
|
|
135
|
+
|
|
136
|
+
merged["_base_sha"] = sha
|
|
137
|
+
overlay_path.write_text(json.dumps(merged, indent=2) + "\n")
|
|
138
|
+
return {k: v for k, v in merged.items() if k != "_base_sha"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def config_show(
|
|
142
|
+
base_path: Path,
|
|
143
|
+
overlay_dir: Path,
|
|
144
|
+
key: str | None = None,
|
|
145
|
+
*,
|
|
146
|
+
strict: bool = False,
|
|
147
|
+
) -> Any:
|
|
148
|
+
"""Read config with overlay-first, base-fallback resolution.
|
|
149
|
+
|
|
150
|
+
If key is None, returns the full merged dict.
|
|
151
|
+
If key is a dotted path (e.g. "agents.opus.model"), resolves to that value.
|
|
152
|
+
Returns None for missing keys.
|
|
153
|
+
"""
|
|
154
|
+
base_content = base_path.read_text()
|
|
155
|
+
base_data = toml.loads(base_content)
|
|
156
|
+
|
|
157
|
+
overlay_path = overlay_dir / f"{_overlay_stem(base_path)}.json"
|
|
158
|
+
overlay_data: dict = {}
|
|
159
|
+
|
|
160
|
+
if overlay_path.exists():
|
|
161
|
+
overlay_data = json.loads(overlay_path.read_text())
|
|
162
|
+
if strict:
|
|
163
|
+
stored_sha = overlay_data.get("_base_sha", "")
|
|
164
|
+
current_sha = _base_sha(base_content)
|
|
165
|
+
if stored_sha != current_sha:
|
|
166
|
+
raise BaseStaleError(
|
|
167
|
+
f"Overlay _base_sha ({stored_sha[:12]}...) does not match "
|
|
168
|
+
f"current base ({current_sha[:12]}...)"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
clean_overlay = {k: v for k, v in overlay_data.items() if k != "_base_sha"}
|
|
172
|
+
merged = _deep_merge(base_data, clean_overlay)
|
|
173
|
+
|
|
174
|
+
# Include prompt overlays in the merged view
|
|
175
|
+
prompts_dir = overlay_dir / "prompts"
|
|
176
|
+
if prompts_dir.is_dir():
|
|
177
|
+
prompts: dict[str, Any] = merged.get("prompts", {})
|
|
178
|
+
for pf in sorted(prompts_dir.glob("*.json")):
|
|
179
|
+
agent_name = pf.stem
|
|
180
|
+
agent_prompts = json.loads(pf.read_text())
|
|
181
|
+
if agent_name in prompts and isinstance(prompts[agent_name], dict):
|
|
182
|
+
prompts[agent_name] = _deep_merge(prompts[agent_name], agent_prompts)
|
|
183
|
+
else:
|
|
184
|
+
prompts[agent_name] = agent_prompts
|
|
185
|
+
if prompts:
|
|
186
|
+
merged["prompts"] = prompts
|
|
187
|
+
|
|
188
|
+
if key is None:
|
|
189
|
+
return merged
|
|
190
|
+
return _resolve_dotted_key(merged, key)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def overlay_write(
|
|
194
|
+
overlay_dir: Path,
|
|
195
|
+
section: str,
|
|
196
|
+
key: str,
|
|
197
|
+
value: Any,
|
|
198
|
+
*,
|
|
199
|
+
agent: str = "",
|
|
200
|
+
policy: WritePolicy | None = None,
|
|
201
|
+
prompt_overlay: bool = False,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Write a value to the overlay. Respects policy enforcement.
|
|
204
|
+
|
|
205
|
+
If prompt_overlay is True, writes to prompts/{agent_name}.json instead
|
|
206
|
+
of the main overlay file.
|
|
207
|
+
"""
|
|
208
|
+
if policy is not None and agent:
|
|
209
|
+
if not policy.can_write(agent=agent, section=section, key=key):
|
|
210
|
+
raise PolicyViolationError(
|
|
211
|
+
f"Agent '{agent}' cannot write to section '{section}' "
|
|
212
|
+
f"under {policy.__class__.__name__}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if prompt_overlay:
|
|
216
|
+
parts = section.split(".")
|
|
217
|
+
if len(parts) >= 2 and parts[0] == "prompts":
|
|
218
|
+
agent_name = parts[1]
|
|
219
|
+
prompts_dir = overlay_dir / "prompts"
|
|
220
|
+
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
prompt_path = prompts_dir / f"{agent_name}.json"
|
|
222
|
+
|
|
223
|
+
data: dict = {}
|
|
224
|
+
if prompt_path.exists():
|
|
225
|
+
data = json.loads(prompt_path.read_text())
|
|
226
|
+
data[key] = value
|
|
227
|
+
prompt_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
overlay_path = overlay_dir / "agents.json"
|
|
231
|
+
if not overlay_path.exists():
|
|
232
|
+
raise FileNotFoundError(
|
|
233
|
+
f"Overlay file not found: {overlay_path}. Run config_apply first."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
data = json.loads(overlay_path.read_text())
|
|
237
|
+
_set_nested(data, section, key, value)
|
|
238
|
+
overlay_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def config_restore(workspace: Path, ref: str, overlay_dir: Path) -> dict:
|
|
242
|
+
"""Restore overlay files from a grip commit's config/ subtree."""
|
|
243
|
+
from gr2.python_cli.grip import _grip_git
|
|
244
|
+
|
|
245
|
+
proc = _grip_git(workspace, "ls-tree", f"{ref}:config")
|
|
246
|
+
if proc.returncode != 0:
|
|
247
|
+
return {}
|
|
248
|
+
|
|
249
|
+
overlay_dir.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
restored: dict[str, str] = {}
|
|
251
|
+
|
|
252
|
+
for line in proc.stdout.strip().splitlines():
|
|
253
|
+
if not line.strip():
|
|
254
|
+
continue
|
|
255
|
+
parts = line.split("\t")
|
|
256
|
+
if len(parts) < 2:
|
|
257
|
+
continue
|
|
258
|
+
meta, name = parts[0], parts[1]
|
|
259
|
+
obj_type = meta.split()[1]
|
|
260
|
+
|
|
261
|
+
if obj_type == "blob":
|
|
262
|
+
blob = _grip_git(workspace, "show", f"{ref}:config/{name}")
|
|
263
|
+
if blob.returncode == 0:
|
|
264
|
+
(overlay_dir / name).write_text(blob.stdout)
|
|
265
|
+
restored[name] = "restored"
|
|
266
|
+
elif obj_type == "tree" and name == "prompts":
|
|
267
|
+
_restore_prompts(workspace, ref, overlay_dir)
|
|
268
|
+
|
|
269
|
+
return restored
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _restore_prompts(workspace: Path, ref: str, overlay_dir: Path) -> None:
|
|
273
|
+
from gr2.python_cli.grip import _grip_git
|
|
274
|
+
|
|
275
|
+
prompts_dir = overlay_dir / "prompts"
|
|
276
|
+
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
|
|
278
|
+
proc = _grip_git(workspace, "ls-tree", f"{ref}:config/prompts")
|
|
279
|
+
if proc.returncode != 0:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
for line in proc.stdout.strip().splitlines():
|
|
283
|
+
if not line.strip():
|
|
284
|
+
continue
|
|
285
|
+
parts = line.split("\t")
|
|
286
|
+
if len(parts) < 2:
|
|
287
|
+
continue
|
|
288
|
+
name = parts[1]
|
|
289
|
+
blob = _grip_git(workspace, "show", f"{ref}:config/prompts/{name}")
|
|
290
|
+
if blob.returncode == 0:
|
|
291
|
+
(prompts_dir / name).write_text(blob.stdout)
|