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/objects.py ADDED
@@ -0,0 +1,412 @@
1
+ """Overlay object encoding: capture source files into git objects, apply them back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from gr2.overlay.types import OverlayMeta, OverlayRef, OverlayTier
9
+
10
+ TIER_A_EXTENSIONS = frozenset({".toml", ".yml", ".json"})
11
+ TIER_A_FILENAMES = frozenset({"COMPOSE.md"})
12
+
13
+
14
+ def capture_overlay_object(
15
+ overlay_store: Path,
16
+ source_root: Path,
17
+ metadata: OverlayMeta,
18
+ ) -> None:
19
+ if metadata.tier == OverlayTier.B:
20
+ _capture_tier_b(overlay_store, source_root, metadata)
21
+ else:
22
+ _capture_tier_a(overlay_store, source_root, metadata)
23
+
24
+
25
+ def _capture_tier_a(
26
+ overlay_store: Path,
27
+ source_root: Path,
28
+ metadata: OverlayMeta,
29
+ ) -> None:
30
+ tier_a_files = _collect_tier_a_files(source_root)
31
+
32
+ blobs: dict[str, str] = {}
33
+ for rel_path, abs_path in tier_a_files:
34
+ blobs[rel_path] = _hash_blob_from_file(overlay_store, abs_path)
35
+
36
+ working_tree_oid = _build_nested_tree(overlay_store, blobs)
37
+ empty_tree_oid = _build_nested_tree(overlay_store, {})
38
+ meta_blob_oid = _hash_blob_from_bytes(overlay_store, _serialize_metadata(metadata))
39
+
40
+ _finalize_overlay_tag(
41
+ overlay_store, metadata, meta_blob_oid, working_tree_oid, working_tree_oid, empty_tree_oid
42
+ )
43
+
44
+
45
+ def _capture_tier_b(
46
+ overlay_store: Path,
47
+ source_root: Path,
48
+ metadata: OverlayMeta,
49
+ ) -> None:
50
+ dirty_files = _get_dirty_tracked_files(source_root)
51
+
52
+ staged_blobs: dict[str, str] = {}
53
+ working_blobs: dict[str, str] = {}
54
+ deleted_paths: list[str] = []
55
+
56
+ for rel_path in dirty_files:
57
+ index_oid = _get_index_blob_oid(source_root, rel_path)
58
+ abs_path = source_root / rel_path
59
+
60
+ if index_oid is None and not abs_path.exists():
61
+ deleted_paths.append(rel_path)
62
+ continue
63
+
64
+ if index_oid is not None:
65
+ index_content = _read_blob_from_repo(source_root, index_oid)
66
+ staged_blobs[rel_path] = _hash_blob_from_bytes(overlay_store, index_content)
67
+
68
+ if abs_path.exists():
69
+ working_blobs[rel_path] = _hash_blob_from_file(overlay_store, abs_path)
70
+
71
+ staged_tree_oid = _build_nested_tree(overlay_store, staged_blobs)
72
+ working_tree_oid = _build_nested_tree(overlay_store, working_blobs)
73
+ empty_tree_oid = _build_nested_tree(overlay_store, {})
74
+
75
+ repo_state = _get_repo_state(source_root)
76
+ meta_blob_oid = _hash_blob_from_bytes(
77
+ overlay_store,
78
+ _serialize_metadata(metadata, repo_state=repo_state, deleted_paths=deleted_paths),
79
+ )
80
+
81
+ _finalize_overlay_tag(
82
+ overlay_store, metadata, meta_blob_oid, staged_tree_oid, working_tree_oid, empty_tree_oid
83
+ )
84
+
85
+
86
+ def apply_overlay_object(
87
+ overlay_store: Path,
88
+ overlay_ref: OverlayRef,
89
+ checkout_root: Path,
90
+ ) -> None:
91
+ tag_oid = _git_output(overlay_store, "rev-parse", overlay_ref.ref_path)
92
+ structured_tree_oid = _git_output(overlay_store, "rev-parse", f"{tag_oid}^{{tree}}")
93
+
94
+ meta_blob = _read_metadata_blob(overlay_store, structured_tree_oid)
95
+ is_tier_b = 'tier = "source"' in meta_blob
96
+
97
+ _write_tree_to_disk(overlay_store, structured_tree_oid, "working_tree_tree", checkout_root)
98
+
99
+ if is_tier_b:
100
+ _apply_staged_index(overlay_store, structured_tree_oid, checkout_root)
101
+ _apply_deletions(meta_blob, checkout_root)
102
+
103
+
104
+ def _finalize_overlay_tag(
105
+ overlay_store: Path,
106
+ metadata: OverlayMeta,
107
+ meta_blob_oid: str,
108
+ staged_tree_oid: str,
109
+ working_tree_oid: str,
110
+ untracked_tree_oid: str,
111
+ ) -> None:
112
+ structured_input = "\n".join(
113
+ [
114
+ f"100644 blob {meta_blob_oid}\tmetadata_blob",
115
+ f"040000 tree {staged_tree_oid}\tstaged_index_tree",
116
+ f"040000 tree {untracked_tree_oid}\tuntracked_blobs",
117
+ f"040000 tree {working_tree_oid}\tworking_tree_tree",
118
+ ]
119
+ )
120
+ structured_tree_oid = _git_input(overlay_store, structured_input, "mktree")
121
+
122
+ tag_content = (
123
+ f"object {structured_tree_oid}\n"
124
+ f"type tree\n"
125
+ f"tag {metadata.ref.name}\n"
126
+ f"tagger overlay-system <overlay@gr2> 0 +0000\n"
127
+ f"\n"
128
+ f"overlay: {metadata.ref.ref_path}\n"
129
+ )
130
+ tag_oid = _git_input(overlay_store, tag_content, "mktag")
131
+ _git_run(overlay_store, "update-ref", metadata.ref.ref_path, tag_oid)
132
+
133
+
134
+ def _get_dirty_tracked_files(source_root: Path) -> list[str]:
135
+ staged = _git_lines_in_repo(source_root, "diff-index", "--cached", "--name-only", "HEAD")
136
+ unstaged = _git_lines_in_repo(source_root, "diff-files", "--name-only")
137
+ return sorted(set(staged) | set(unstaged))
138
+
139
+
140
+ def _get_index_blob_oid(source_root: Path, rel_path: str) -> str | None:
141
+ output = _git_output_in_repo(source_root, "ls-files", "-s", rel_path)
142
+ if not output:
143
+ return None
144
+ return output.split()[1]
145
+
146
+
147
+ def _read_blob_from_repo(repo_root: Path, blob_oid: str) -> bytes:
148
+ result = subprocess.run(
149
+ ["git", "cat-file", "blob", blob_oid],
150
+ cwd=repo_root,
151
+ check=True,
152
+ capture_output=True,
153
+ )
154
+ return result.stdout
155
+
156
+
157
+ def _get_repo_state(source_root: Path) -> dict[str, object]:
158
+ head_commit = _git_output_in_repo(source_root, "rev-parse", "HEAD")
159
+ try:
160
+ head_ref = _git_output_in_repo(source_root, "symbolic-ref", "HEAD")
161
+ head_branch = head_ref.removeprefix("refs/heads/")
162
+ head_detached = False
163
+ except subprocess.CalledProcessError:
164
+ head_ref = ""
165
+ head_branch = ""
166
+ head_detached = True
167
+ return {
168
+ "repo": ".",
169
+ "head_branch": head_branch,
170
+ "head_ref": head_ref,
171
+ "head_detached": head_detached,
172
+ "head_commit": head_commit,
173
+ }
174
+
175
+
176
+ def _read_metadata_blob(overlay_store: Path, structured_tree_oid: str) -> str:
177
+ return _git_output(overlay_store, "show", f"{structured_tree_oid}:metadata_blob")
178
+
179
+
180
+ def _apply_deletions(meta_blob: str, checkout_root: Path) -> None:
181
+ deleted_paths = _parse_deleted_paths(meta_blob)
182
+ for rel_path in deleted_paths:
183
+ target = checkout_root / rel_path
184
+ if target.exists():
185
+ target.unlink()
186
+ parent = target.parent
187
+ if parent != checkout_root and not any(parent.iterdir()):
188
+ parent.rmdir()
189
+ subprocess.run(
190
+ ["git", "update-index", "--force-remove", rel_path],
191
+ cwd=checkout_root,
192
+ check=True,
193
+ capture_output=True,
194
+ text=True,
195
+ )
196
+
197
+
198
+ def _parse_deleted_paths(meta_blob: str) -> list[str]:
199
+ import tomllib
200
+
201
+ try:
202
+ data = tomllib.loads(meta_blob)
203
+ except tomllib.TOMLDecodeError:
204
+ return []
205
+ return data.get("deleted_paths", [])
206
+
207
+
208
+ def _write_tree_to_disk(
209
+ overlay_store: Path,
210
+ structured_tree_oid: str,
211
+ tree_name: str,
212
+ target_root: Path,
213
+ ) -> None:
214
+ tree_line = _git_output(overlay_store, "ls-tree", structured_tree_oid, tree_name)
215
+ tree_oid = tree_line.split()[2]
216
+
217
+ ls_output = _git_output(overlay_store, "ls-tree", "-r", tree_oid)
218
+ if not ls_output:
219
+ return
220
+
221
+ for line in ls_output.splitlines():
222
+ meta_part, file_path = line.split("\t", 1)
223
+ blob_oid = meta_part.split()[2]
224
+
225
+ content = subprocess.run(
226
+ ["git", f"--git-dir={overlay_store}", "cat-file", "blob", blob_oid],
227
+ check=True,
228
+ capture_output=True,
229
+ ).stdout
230
+
231
+ target = target_root / file_path
232
+ target.parent.mkdir(parents=True, exist_ok=True)
233
+ target.write_bytes(content)
234
+
235
+
236
+ def _apply_staged_index(
237
+ overlay_store: Path,
238
+ structured_tree_oid: str,
239
+ checkout_root: Path,
240
+ ) -> None:
241
+ staged_line = _git_output(overlay_store, "ls-tree", structured_tree_oid, "staged_index_tree")
242
+ staged_tree_oid = staged_line.split()[2]
243
+
244
+ ls_output = _git_output(overlay_store, "ls-tree", "-r", staged_tree_oid)
245
+ if not ls_output:
246
+ return
247
+
248
+ for line in ls_output.splitlines():
249
+ meta_part, file_path = line.split("\t", 1)
250
+ blob_oid = meta_part.split()[2]
251
+
252
+ content = subprocess.run(
253
+ ["git", f"--git-dir={overlay_store}", "cat-file", "blob", blob_oid],
254
+ check=True,
255
+ capture_output=True,
256
+ ).stdout
257
+
258
+ result = subprocess.run(
259
+ ["git", "hash-object", "-w", "--stdin"],
260
+ cwd=checkout_root,
261
+ input=content,
262
+ check=True,
263
+ capture_output=True,
264
+ )
265
+ local_oid = result.stdout.strip().decode()
266
+
267
+ subprocess.run(
268
+ ["git", "update-index", "--add", "--cacheinfo", f"100644,{local_oid},{file_path}"],
269
+ cwd=checkout_root,
270
+ check=True,
271
+ capture_output=True,
272
+ text=True,
273
+ )
274
+
275
+
276
+ def _git_output_in_repo(repo_root: Path, *args: str) -> str:
277
+ result = subprocess.run(
278
+ ["git", *args],
279
+ cwd=repo_root,
280
+ check=True,
281
+ capture_output=True,
282
+ text=True,
283
+ )
284
+ return result.stdout.strip()
285
+
286
+
287
+ def _git_lines_in_repo(repo_root: Path, *args: str) -> list[str]:
288
+ result = subprocess.run(
289
+ ["git", *args],
290
+ cwd=repo_root,
291
+ check=True,
292
+ capture_output=True,
293
+ text=True,
294
+ )
295
+ return [line for line in result.stdout.splitlines() if line]
296
+
297
+
298
+ def _collect_tier_a_files(source_root: Path) -> list[tuple[str, Path]]:
299
+ result = []
300
+ for path in sorted(source_root.rglob("*")):
301
+ if not path.is_file():
302
+ continue
303
+ if path.suffix in TIER_A_EXTENSIONS or path.name in TIER_A_FILENAMES:
304
+ rel = str(path.relative_to(source_root)).replace("\\", "/")
305
+ result.append((rel, path))
306
+ return result
307
+
308
+
309
+ def _build_nested_tree(overlay_store: Path, blobs: dict[str, str]) -> str:
310
+ direct: list[tuple[str, str, str, str]] = []
311
+ subdirs: dict[str, dict[str, str]] = {}
312
+
313
+ for path, blob_oid in blobs.items():
314
+ parts = path.split("/", 1)
315
+ if len(parts) == 1:
316
+ direct.append((parts[0], "100644", "blob", blob_oid))
317
+ else:
318
+ subdirs.setdefault(parts[0], {})[parts[1]] = blob_oid
319
+
320
+ for dirname, sub_blobs in sorted(subdirs.items()):
321
+ sub_oid = _build_nested_tree(overlay_store, sub_blobs)
322
+ direct.append((dirname, "040000", "tree", sub_oid))
323
+
324
+ direct.sort(key=lambda e: e[0])
325
+ tree_input = "\n".join(f"{mode} {kind} {oid}\t{name}" for name, mode, kind, oid in direct)
326
+ return _git_input(overlay_store, tree_input, "mktree")
327
+
328
+
329
+ def _hash_blob_from_file(overlay_store: Path, file_path: Path) -> str:
330
+ return _git_output(overlay_store, "hash-object", "-w", str(file_path))
331
+
332
+
333
+ def _hash_blob_from_bytes(overlay_store: Path, data: bytes) -> str:
334
+ result = subprocess.run(
335
+ ["git", f"--git-dir={overlay_store}", "hash-object", "-w", "--stdin"],
336
+ input=data,
337
+ check=True,
338
+ capture_output=True,
339
+ )
340
+ return result.stdout.strip().decode()
341
+
342
+
343
+ def _serialize_metadata(
344
+ meta: OverlayMeta,
345
+ *,
346
+ repo_state: dict[str, object] | None = None,
347
+ deleted_paths: list[str] | None = None,
348
+ ) -> bytes:
349
+ lines = [
350
+ f'author = "{meta.author}"',
351
+ f'signature = "{meta.signature}"',
352
+ f'tier = "{meta.tier}"',
353
+ f'timestamp = "{meta.timestamp}"',
354
+ f'trust = "{meta.trust}"',
355
+ ]
356
+ if deleted_paths:
357
+ items = ", ".join(f'"{p}"' for p in deleted_paths)
358
+ lines.append(f"deleted_paths = [{items}]")
359
+ elif deleted_paths is not None:
360
+ lines.append("deleted_paths = []")
361
+ lines.append("")
362
+ lines.append("[ref]")
363
+ lines.append(f'author = "{meta.ref.author}"')
364
+ lines.append(f'name = "{meta.ref.name}"')
365
+ if meta.parent_overlay_refs:
366
+ lines.append("")
367
+ lines.append("[parents]")
368
+ lines.append("refs = [")
369
+ for ref in meta.parent_overlay_refs:
370
+ lines.append(f' "{ref}",')
371
+ lines.append("]")
372
+ if repo_state:
373
+ lines.append("")
374
+ lines.append("[[repo_state]]")
375
+ for key in ("repo", "head_branch", "head_ref", "head_detached", "head_commit"):
376
+ value = repo_state.get(key)
377
+ if isinstance(value, bool):
378
+ lines.append(f"{key} = {str(value).lower()}")
379
+ else:
380
+ lines.append(f'{key} = "{value}"')
381
+ lines.append("")
382
+ return "\n".join(lines).encode()
383
+
384
+
385
+ def _git_output(git_dir: Path, *args: str) -> str:
386
+ result = subprocess.run(
387
+ ["git", f"--git-dir={git_dir}", *args],
388
+ check=True,
389
+ capture_output=True,
390
+ text=True,
391
+ )
392
+ return result.stdout.strip()
393
+
394
+
395
+ def _git_input(git_dir: Path, stdin_data: str, *args: str) -> str:
396
+ result = subprocess.run(
397
+ ["git", f"--git-dir={git_dir}", *args],
398
+ input=stdin_data,
399
+ check=True,
400
+ capture_output=True,
401
+ text=True,
402
+ )
403
+ return result.stdout.strip()
404
+
405
+
406
+ def _git_run(git_dir: Path, *args: str) -> None:
407
+ subprocess.run(
408
+ ["git", f"--git-dir={git_dir}", *args],
409
+ check=True,
410
+ capture_output=True,
411
+ text=True,
412
+ )
gr2/overlay/perf.py ADDED
@@ -0,0 +1,251 @@
1
+ """Performance gates: measurement harness for overlay vs git baseline comparisons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class PerfGateResult:
13
+ gate: str
14
+ baseline_seconds: float
15
+ candidate_seconds: float
16
+ ratio: float
17
+ threshold: float
18
+ passed: bool
19
+
20
+ def to_json(self) -> dict[str, Any]:
21
+ return {
22
+ "gate": self.gate,
23
+ "baseline_seconds": self.baseline_seconds,
24
+ "candidate_seconds": self.candidate_seconds,
25
+ "ratio": self.ratio,
26
+ "threshold": self.threshold,
27
+ "passed": self.passed,
28
+ }
29
+
30
+
31
+ class PerfGateFailure(Exception):
32
+ def __init__(self, gate: str, ratio: float, threshold: float) -> None:
33
+ super().__init__(
34
+ f"Perf gate '{gate}' failed: ratio {ratio:.2f} exceeds threshold {threshold:.2f}"
35
+ )
36
+ self.gate = gate
37
+ self.ratio = ratio
38
+ self.threshold = threshold
39
+
40
+
41
+ def evaluate_activate_perf_gate(
42
+ activate_op: Callable[[], None] | list[float],
43
+ git_checkout_op: Callable[[], None] | list[float],
44
+ samples: int,
45
+ max_ratio: float,
46
+ clock: Callable[[], float] | None = None,
47
+ ) -> PerfGateResult:
48
+ candidate = _collect_samples(activate_op, samples, clock)
49
+ baseline = _collect_samples(git_checkout_op, samples, clock)
50
+
51
+ candidate_median = statistics.median(candidate)
52
+ baseline_median = statistics.median(baseline)
53
+ ratio = candidate_median / baseline_median
54
+
55
+ return PerfGateResult(
56
+ gate="activate_vs_git_checkout_single_file",
57
+ baseline_seconds=baseline_median,
58
+ candidate_seconds=candidate_median,
59
+ ratio=ratio,
60
+ threshold=max_ratio,
61
+ passed=ratio < max_ratio,
62
+ )
63
+
64
+
65
+ def evaluate_status_diff_perf_gates(
66
+ overlay_status_op: Callable[[], None] | list[float],
67
+ git_status_op: Callable[[], None] | list[float],
68
+ overlay_diff_op: Callable[[], None] | list[float],
69
+ git_diff_op: Callable[[], None] | list[float],
70
+ samples: int,
71
+ max_ratio: float,
72
+ clock: Callable[[], float] | None = None,
73
+ ) -> dict[str, PerfGateResult]:
74
+ status_candidate = _collect_samples(overlay_status_op, samples, clock)
75
+ status_baseline = _collect_samples(git_status_op, samples, clock)
76
+ status_c = statistics.median(status_candidate)
77
+ status_b = statistics.median(status_baseline)
78
+ status_ratio = status_c / status_b
79
+
80
+ diff_candidate = _collect_samples(overlay_diff_op, samples, clock)
81
+ diff_baseline = _collect_samples(git_diff_op, samples, clock)
82
+ diff_c = statistics.median(diff_candidate)
83
+ diff_b = statistics.median(diff_baseline)
84
+ diff_ratio = diff_c / diff_b
85
+
86
+ return {
87
+ "status_vs_git_status": PerfGateResult(
88
+ gate="status_vs_git_status",
89
+ baseline_seconds=status_b,
90
+ candidate_seconds=status_c,
91
+ ratio=status_ratio,
92
+ threshold=max_ratio,
93
+ passed=status_ratio < max_ratio,
94
+ ),
95
+ "diff_vs_git_diff": PerfGateResult(
96
+ gate="diff_vs_git_diff",
97
+ baseline_seconds=diff_b,
98
+ candidate_seconds=diff_c,
99
+ ratio=diff_ratio,
100
+ threshold=max_ratio,
101
+ passed=diff_ratio < max_ratio,
102
+ ),
103
+ }
104
+
105
+
106
+ def assert_perf_gates(results: list[PerfGateResult]) -> None:
107
+ for result in results:
108
+ if not result.passed:
109
+ raise PerfGateFailure(
110
+ gate=result.gate,
111
+ ratio=result.ratio,
112
+ threshold=result.threshold,
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class TierBPerfGateResult:
118
+ status: str
119
+ error_code: str
120
+ capture_ratio: float
121
+ apply_ratio: float
122
+ sample_count: int
123
+ capture_baseline_command: str
124
+ apply_baseline_command: str
125
+
126
+
127
+ def evaluate_tier_b_perf_gate(
128
+ *,
129
+ capture_samples_ms: list[float],
130
+ capture_baseline_samples_ms: list[float],
131
+ apply_samples_ms: list[float],
132
+ apply_baseline_samples_ms: list[float],
133
+ capture_baseline_command: str,
134
+ apply_baseline_command: str,
135
+ ratio_gate: float,
136
+ sample_count: int,
137
+ ) -> TierBPerfGateResult:
138
+ all_lists = [
139
+ capture_samples_ms,
140
+ capture_baseline_samples_ms,
141
+ apply_samples_ms,
142
+ apply_baseline_samples_ms,
143
+ ]
144
+ if any(not s for s in all_lists):
145
+ raise ValueError(f"sample_count {sample_count}: all sample lists must be non-empty")
146
+ if any(len(s) != sample_count for s in all_lists):
147
+ raise ValueError(
148
+ f"sample_count {sample_count}: all sample lists must match, "
149
+ f"got lengths {[len(s) for s in all_lists]}"
150
+ )
151
+ for samples in all_lists:
152
+ for v in samples:
153
+ if v <= 0:
154
+ raise ValueError(f"All durations must be positive, got {v}")
155
+
156
+ capture_ratio = statistics.median(capture_samples_ms) / statistics.median(
157
+ capture_baseline_samples_ms
158
+ )
159
+ apply_ratio = statistics.median(apply_samples_ms) / statistics.median(apply_baseline_samples_ms)
160
+
161
+ if capture_ratio >= ratio_gate or apply_ratio >= ratio_gate:
162
+ status = "degraded"
163
+ error_code = "tier_b_perf_gate_failed"
164
+ else:
165
+ status = "ok"
166
+ error_code = ""
167
+
168
+ return TierBPerfGateResult(
169
+ status=status,
170
+ error_code=error_code,
171
+ capture_ratio=capture_ratio,
172
+ apply_ratio=apply_ratio,
173
+ sample_count=sample_count,
174
+ capture_baseline_command=capture_baseline_command,
175
+ apply_baseline_command=apply_baseline_command,
176
+ )
177
+
178
+
179
+ @dataclass
180
+ class CrossRepoPerfGateResult:
181
+ status: str
182
+ error_code: str
183
+ apply_ratio: float
184
+ sample_count: int
185
+ repo_count: int
186
+ baseline_command: str
187
+
188
+
189
+ def evaluate_cross_repo_perf_gate(
190
+ *,
191
+ apply_samples_ms: list[float],
192
+ baseline_samples_ms: list[float],
193
+ baseline_command: str,
194
+ ratio_gate: float,
195
+ sample_count: int,
196
+ repo_count: int,
197
+ ) -> CrossRepoPerfGateResult:
198
+ if repo_count <= 0:
199
+ raise ValueError(f"repo_count must be positive, got {repo_count}")
200
+ if not baseline_command:
201
+ raise ValueError("baseline_command must not be empty")
202
+ if sample_count < 2:
203
+ raise ValueError(f"sample_count must be >= 2 for statistical validity, got {sample_count}")
204
+
205
+ all_lists = [apply_samples_ms, baseline_samples_ms]
206
+ if any(not s for s in all_lists):
207
+ raise ValueError(f"sample_count {sample_count}: all sample lists must be non-empty")
208
+ if any(len(s) != sample_count for s in all_lists):
209
+ raise ValueError(
210
+ f"sample_count {sample_count}: all sample lists must match, "
211
+ f"got lengths {[len(s) for s in all_lists]}"
212
+ )
213
+ for samples in all_lists:
214
+ for v in samples:
215
+ if v <= 0:
216
+ raise ValueError(f"All durations must be positive, got {v}")
217
+
218
+ apply_ratio = statistics.median(apply_samples_ms) / statistics.median(baseline_samples_ms)
219
+
220
+ if apply_ratio >= ratio_gate:
221
+ status = "degraded"
222
+ error_code = "cross_repo_perf_gate_failed"
223
+ else:
224
+ status = "ok"
225
+ error_code = ""
226
+
227
+ return CrossRepoPerfGateResult(
228
+ status=status,
229
+ error_code=error_code,
230
+ apply_ratio=apply_ratio,
231
+ sample_count=sample_count,
232
+ repo_count=repo_count,
233
+ baseline_command=baseline_command,
234
+ )
235
+
236
+
237
+ def _collect_samples(
238
+ op: Callable[[], None] | list[float],
239
+ count: int,
240
+ clock: Callable[[], float] | None,
241
+ ) -> list[float]:
242
+ if isinstance(op, list):
243
+ return op
244
+
245
+ samples: list[float] = []
246
+ for _ in range(count):
247
+ start = clock()
248
+ op()
249
+ end = clock()
250
+ samples.append(end - start)
251
+ return samples
gr2/overlay/refs.py ADDED
@@ -0,0 +1,36 @@
1
+ """Overlay ref transport: push and fetch overlay refs between bare stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from gr2.overlay.types import OverlayRef
9
+
10
+
11
+ def push_overlay_ref(
12
+ overlay_store: Path,
13
+ remote_store: Path,
14
+ overlay_ref: OverlayRef,
15
+ ) -> None:
16
+ refspec = f"{overlay_ref.ref_path}:{overlay_ref.ref_path}"
17
+ subprocess.run(
18
+ ["git", f"--git-dir={overlay_store}", "push", str(remote_store), refspec],
19
+ check=True,
20
+ capture_output=True,
21
+ text=True,
22
+ )
23
+
24
+
25
+ def fetch_overlay_ref(
26
+ overlay_store: Path,
27
+ remote_store: Path,
28
+ overlay_ref: OverlayRef,
29
+ ) -> None:
30
+ refspec = f"{overlay_ref.ref_path}:{overlay_ref.ref_path}"
31
+ subprocess.run(
32
+ ["git", f"--git-dir={overlay_store}", "fetch", str(remote_store), refspec],
33
+ check=True,
34
+ capture_output=True,
35
+ text=True,
36
+ )