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/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
|
+
)
|