gitgrip 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,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)
@@ -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)