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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Cross-repo atomic overlay activation with rollback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from gr2.overlay.activate import (
|
|
10
|
+
OverlayActivationError,
|
|
11
|
+
activate_overlay,
|
|
12
|
+
deactivate_overlay,
|
|
13
|
+
)
|
|
14
|
+
from gr2.overlay.types import OverlayRef
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RepoOverlayTarget:
|
|
19
|
+
repo_name: str
|
|
20
|
+
checkout_root: Path
|
|
21
|
+
overlay_store: Path
|
|
22
|
+
overlay_ref: OverlayRef
|
|
23
|
+
overlay_source_kind: str
|
|
24
|
+
overlay_source_value: str | None
|
|
25
|
+
overlay_signer: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CrossRepoActivationResult:
|
|
30
|
+
status: str
|
|
31
|
+
completed_repos: list[str] = field(default_factory=list)
|
|
32
|
+
rolled_back_repos: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CrossRepoActivationError(Exception):
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
*,
|
|
40
|
+
error_code: str,
|
|
41
|
+
failing_repo: str,
|
|
42
|
+
rolled_back_repos: list[str],
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.error_code = error_code
|
|
46
|
+
self.failing_repo = failing_repo
|
|
47
|
+
self.rolled_back_repos = rolled_back_repos
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def activate_overlays_atomically(
|
|
51
|
+
targets: list[RepoOverlayTarget],
|
|
52
|
+
) -> CrossRepoActivationResult:
|
|
53
|
+
snapshots: dict[str, dict[str, str]] = {}
|
|
54
|
+
applied: list[RepoOverlayTarget] = []
|
|
55
|
+
|
|
56
|
+
for target in targets:
|
|
57
|
+
snapshots[target.repo_name] = _snapshot(target.checkout_root)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
for target in targets:
|
|
61
|
+
activate_overlay(
|
|
62
|
+
workspace_root=target.checkout_root,
|
|
63
|
+
overlay_store=target.overlay_store,
|
|
64
|
+
overlay_ref=target.overlay_ref,
|
|
65
|
+
overlay_source_kind=target.overlay_source_kind,
|
|
66
|
+
overlay_source_value=target.overlay_source_value,
|
|
67
|
+
overlay_signer=target.overlay_signer,
|
|
68
|
+
)
|
|
69
|
+
applied.append(target)
|
|
70
|
+
except OverlayActivationError as e:
|
|
71
|
+
failing_target = target
|
|
72
|
+
rolled_back: list[str] = []
|
|
73
|
+
|
|
74
|
+
for prev in reversed(applied):
|
|
75
|
+
_restore_snapshot(prev.checkout_root, snapshots[prev.repo_name])
|
|
76
|
+
rolled_back.append(prev.repo_name)
|
|
77
|
+
rolled_back.reverse()
|
|
78
|
+
|
|
79
|
+
if _snapshot(failing_target.checkout_root) != snapshots[failing_target.repo_name]:
|
|
80
|
+
_restore_snapshot(failing_target.checkout_root, snapshots[failing_target.repo_name])
|
|
81
|
+
rolled_back.append(failing_target.repo_name)
|
|
82
|
+
|
|
83
|
+
raise CrossRepoActivationError(
|
|
84
|
+
str(e),
|
|
85
|
+
error_code=e.error_code,
|
|
86
|
+
failing_repo=failing_target.repo_name,
|
|
87
|
+
rolled_back_repos=rolled_back,
|
|
88
|
+
) from e
|
|
89
|
+
|
|
90
|
+
return CrossRepoActivationResult(
|
|
91
|
+
status="ok",
|
|
92
|
+
completed_repos=[t.repo_name for t in applied],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _snapshot(root: Path) -> dict[str, bytes]:
|
|
97
|
+
result: dict[str, bytes] = {}
|
|
98
|
+
for path in sorted(root.rglob("*")):
|
|
99
|
+
if path.is_file():
|
|
100
|
+
result[str(path.relative_to(root))] = path.read_bytes()
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _restore_snapshot(root: Path, snapshot: dict[str, bytes | str]) -> None:
|
|
105
|
+
current_files = set()
|
|
106
|
+
for path in root.rglob("*"):
|
|
107
|
+
if path.is_file():
|
|
108
|
+
current_files.add(str(path.relative_to(root)))
|
|
109
|
+
|
|
110
|
+
for rel_path in current_files - set(snapshot.keys()):
|
|
111
|
+
target = root / rel_path
|
|
112
|
+
target.unlink()
|
|
113
|
+
|
|
114
|
+
for rel_path, content in snapshot.items():
|
|
115
|
+
target = root / rel_path
|
|
116
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
if isinstance(content, str):
|
|
118
|
+
target.write_text(content)
|
|
119
|
+
else:
|
|
120
|
+
target.write_bytes(content)
|
|
121
|
+
|
|
122
|
+
for dirpath in sorted(root.rglob("*"), reverse=True):
|
|
123
|
+
if dirpath.is_dir() and not any(dirpath.iterdir()):
|
|
124
|
+
dirpath.rmdir()
|
gr2/overlay/drivers.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Curated overlay merge drivers: deep, prepend, union."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from gr2.overlay.types import OverlayRef
|
|
15
|
+
|
|
16
|
+
CURATED_DRIVERS: dict[str, str] = {
|
|
17
|
+
"overlay-deep": "deep",
|
|
18
|
+
"overlay-prepend": "prepend",
|
|
19
|
+
"overlay-union": "union",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def install_driver_registry() -> None:
|
|
24
|
+
home = Path(os.environ["HOME"])
|
|
25
|
+
gitconfig = home / ".gitconfig"
|
|
26
|
+
|
|
27
|
+
existing = gitconfig.read_text() if gitconfig.exists() else ""
|
|
28
|
+
|
|
29
|
+
sections: list[str] = []
|
|
30
|
+
for driver_name in CURATED_DRIVERS:
|
|
31
|
+
header = f'[merge "{driver_name}"]'
|
|
32
|
+
if header not in existing:
|
|
33
|
+
sections.append(
|
|
34
|
+
f"{header}\n"
|
|
35
|
+
f"\tname = {driver_name}\n"
|
|
36
|
+
f"\tdriver = gr2-overlay-driver {driver_name} %O %A %B %P\n"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if sections:
|
|
40
|
+
with gitconfig.open("a") as f:
|
|
41
|
+
f.write("\n".join(sections) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def invoke_driver(
|
|
45
|
+
driver_name: str,
|
|
46
|
+
ancestor: Path,
|
|
47
|
+
current: Path,
|
|
48
|
+
other: Path,
|
|
49
|
+
relative_path: str,
|
|
50
|
+
*,
|
|
51
|
+
source_overlay: OverlayRef,
|
|
52
|
+
trusted_overlay_sources: set[str],
|
|
53
|
+
) -> None:
|
|
54
|
+
if driver_name not in CURATED_DRIVERS:
|
|
55
|
+
raise ValueError(f"Unknown overlay driver: {driver_name}")
|
|
56
|
+
|
|
57
|
+
if source_overlay.ref_path not in trusted_overlay_sources:
|
|
58
|
+
raise PermissionError(f"Overlay source {source_overlay.ref_path} is not in the allowlist")
|
|
59
|
+
|
|
60
|
+
handlers = {
|
|
61
|
+
"overlay-deep": _driver_deep,
|
|
62
|
+
"overlay-prepend": _driver_prepend,
|
|
63
|
+
"overlay-union": _driver_union,
|
|
64
|
+
}
|
|
65
|
+
handlers[driver_name](ancestor, current, other, relative_path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _driver_deep(ancestor: Path, current: Path, other: Path, relative_path: str) -> None:
|
|
69
|
+
suffix = Path(relative_path).suffix
|
|
70
|
+
|
|
71
|
+
if suffix == ".toml":
|
|
72
|
+
current_data = tomllib.loads(current.read_text())
|
|
73
|
+
other_data = tomllib.loads(other.read_text())
|
|
74
|
+
merged = _deep_merge(current_data, other_data)
|
|
75
|
+
current.write_bytes(tomli_w.dumps(merged).encode())
|
|
76
|
+
elif suffix in {".yml", ".yaml"}:
|
|
77
|
+
current_data = yaml.safe_load(current.read_text()) or {}
|
|
78
|
+
other_data = yaml.safe_load(other.read_text()) or {}
|
|
79
|
+
merged = _deep_merge(current_data, other_data)
|
|
80
|
+
current.write_text(yaml.dump(merged, default_flow_style=False))
|
|
81
|
+
elif suffix == ".json":
|
|
82
|
+
current_data = json.loads(current.read_text())
|
|
83
|
+
other_data = json.loads(other.read_text())
|
|
84
|
+
merged = _deep_merge(current_data, other_data)
|
|
85
|
+
current.write_text(json.dumps(merged, indent=2) + "\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
89
|
+
result = dict(base)
|
|
90
|
+
for key, value in overlay.items():
|
|
91
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
92
|
+
result[key] = _deep_merge(result[key], value)
|
|
93
|
+
else:
|
|
94
|
+
result[key] = value
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _driver_prepend(ancestor: Path, current: Path, other: Path, relative_path: str) -> None:
|
|
99
|
+
current.write_text(other.read_text() + current.read_text())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _driver_union(ancestor: Path, current: Path, other: Path, relative_path: str) -> None:
|
|
103
|
+
current_lines = current.read_text().splitlines()
|
|
104
|
+
other_lines = other.read_text().splitlines()
|
|
105
|
+
|
|
106
|
+
seen = set(current_lines)
|
|
107
|
+
result = list(current_lines)
|
|
108
|
+
for line in other_lines:
|
|
109
|
+
if line not in seen:
|
|
110
|
+
result.append(line)
|
|
111
|
+
seen.add(line)
|
|
112
|
+
|
|
113
|
+
current.write_text("\n".join(result) + "\n" if result else "")
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Overlay introspection: stack, trace, why, impact, status queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gr2.overlay.types import OverlayRef
|
|
11
|
+
|
|
12
|
+
GRIP_DIR = ".grip"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def overlay_stack(
|
|
16
|
+
workspace_root: Path,
|
|
17
|
+
overlay_store: Path,
|
|
18
|
+
json_output: bool,
|
|
19
|
+
) -> str | dict[str, Any]:
|
|
20
|
+
stack_file = workspace_root / GRIP_DIR / "overlay-stack.toml"
|
|
21
|
+
data = _load_toml(stack_file)
|
|
22
|
+
|
|
23
|
+
active_refs = data.get("active", [])
|
|
24
|
+
available_refs = data.get("available", [])
|
|
25
|
+
|
|
26
|
+
active_entries = [_ref_entry(r) for r in active_refs]
|
|
27
|
+
available_entries = [_ref_entry(r) for r in available_refs]
|
|
28
|
+
|
|
29
|
+
if json_output:
|
|
30
|
+
return {"active": active_entries, "available": available_entries}
|
|
31
|
+
|
|
32
|
+
lines = ["Active overlays:"]
|
|
33
|
+
for entry in active_entries:
|
|
34
|
+
lines.append(f" {entry['author']}/{entry['name']} ({entry['ref']})")
|
|
35
|
+
lines.append("Available overlays:")
|
|
36
|
+
for entry in available_entries:
|
|
37
|
+
lines.append(f" {entry['author']}/{entry['name']} ({entry['ref']})")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def overlay_trace(
|
|
42
|
+
workspace_root: Path,
|
|
43
|
+
overlay_store: Path,
|
|
44
|
+
file_path: str,
|
|
45
|
+
json_output: bool,
|
|
46
|
+
) -> str | dict[str, Any]:
|
|
47
|
+
attr_file = workspace_root / GRIP_DIR / "overlay-attribution.toml"
|
|
48
|
+
data = _load_toml(attr_file)
|
|
49
|
+
|
|
50
|
+
file_data = data.get("files", {}).get(file_path, {})
|
|
51
|
+
regions = file_data.get("lines", [])
|
|
52
|
+
|
|
53
|
+
if json_output:
|
|
54
|
+
return {"file": file_path, "regions": regions}
|
|
55
|
+
|
|
56
|
+
lines = [f"Trace for {file_path}:"]
|
|
57
|
+
for region in regions:
|
|
58
|
+
lines.append(f" lines {region['start']}-{region['end']}: {region['ref']}")
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def overlay_why(
|
|
63
|
+
workspace_root: Path,
|
|
64
|
+
overlay_store: Path,
|
|
65
|
+
file_path: str,
|
|
66
|
+
json_output: bool,
|
|
67
|
+
) -> str | dict[str, Any]:
|
|
68
|
+
why_file = workspace_root / GRIP_DIR / "overlay-why.toml"
|
|
69
|
+
data = _load_toml(why_file)
|
|
70
|
+
|
|
71
|
+
file_data = data.get("files", {}).get(file_path, {})
|
|
72
|
+
rule = file_data.get("rule", "")
|
|
73
|
+
reason = file_data.get("reason", "")
|
|
74
|
+
ref = file_data.get("ref", "")
|
|
75
|
+
|
|
76
|
+
if json_output:
|
|
77
|
+
return {"rule": rule, "reason": reason, "ref": ref}
|
|
78
|
+
|
|
79
|
+
return f"{file_path}: rule={rule}, reason={reason} (ref={ref})"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def overlay_impact(
|
|
83
|
+
overlay_store: Path,
|
|
84
|
+
overlay_ref: OverlayRef,
|
|
85
|
+
json_output: bool,
|
|
86
|
+
) -> str | dict[str, Any]:
|
|
87
|
+
files = _read_overlay_file_list(overlay_store, overlay_ref)
|
|
88
|
+
|
|
89
|
+
if json_output:
|
|
90
|
+
return {"files": files}
|
|
91
|
+
|
|
92
|
+
lines = [f"Files touched by {overlay_ref.author}/{overlay_ref.name}:"]
|
|
93
|
+
for f in files:
|
|
94
|
+
lines.append(f" {f}")
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def overlay_status(
|
|
99
|
+
workspace_root: Path,
|
|
100
|
+
overlay_store: Path,
|
|
101
|
+
json_output: bool,
|
|
102
|
+
) -> str | dict[str, Any]:
|
|
103
|
+
status_file = workspace_root / GRIP_DIR / "overlay-status.toml"
|
|
104
|
+
data = _load_toml(status_file)
|
|
105
|
+
|
|
106
|
+
active = data.get("active", [])
|
|
107
|
+
available = data.get("available", [])
|
|
108
|
+
applied = data.get("applied", [])
|
|
109
|
+
|
|
110
|
+
if json_output:
|
|
111
|
+
return {"active": active, "available": available, "applied": applied}
|
|
112
|
+
|
|
113
|
+
lines = [
|
|
114
|
+
"Active: " + ", ".join(active) if active else "Active: (none)",
|
|
115
|
+
"Available: " + ", ".join(available) if available else "Available: (none)",
|
|
116
|
+
"Applied: " + ", ".join(applied) if applied else "Applied: (none)",
|
|
117
|
+
]
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ref_entry(ref_path: str) -> dict[str, str]:
|
|
122
|
+
parts = ref_path.replace("refs/overlays/", "").split("/", 1)
|
|
123
|
+
author = parts[0] if parts else ""
|
|
124
|
+
name = parts[1] if len(parts) > 1 else ""
|
|
125
|
+
return {"ref": ref_path, "author": author, "name": name}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
129
|
+
if not path.exists():
|
|
130
|
+
return {}
|
|
131
|
+
text = path.read_text()
|
|
132
|
+
if not text.strip():
|
|
133
|
+
return {}
|
|
134
|
+
return tomllib.loads(text)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read_overlay_file_list(overlay_store: Path, overlay_ref: OverlayRef) -> list[str]:
|
|
138
|
+
tag_oid = _git_output(overlay_store, "rev-parse", overlay_ref.ref_path)
|
|
139
|
+
structured_tree_oid = _git_output(overlay_store, "rev-parse", f"{tag_oid}^{{tree}}")
|
|
140
|
+
wt_line = _git_output(overlay_store, "ls-tree", structured_tree_oid, "working_tree_tree")
|
|
141
|
+
working_tree_oid = wt_line.split()[2]
|
|
142
|
+
ls_output = _git_output(overlay_store, "ls-tree", "-r", "--name-only", working_tree_oid)
|
|
143
|
+
if not ls_output:
|
|
144
|
+
return []
|
|
145
|
+
return sorted(ls_output.splitlines())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _git_output(git_dir: Path, *args: str) -> str:
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
["git", f"--git-dir={git_dir}", *args],
|
|
151
|
+
check=True,
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
)
|
|
155
|
+
return result.stdout.strip()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""AST-based language-aware merge drivers for overlay composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PythonCompositionConflict(Exception):
|
|
10
|
+
def __init__(self, symbol: str) -> None:
|
|
11
|
+
super().__init__(
|
|
12
|
+
f"Composition conflict: both base and overlay modify '{symbol}'"
|
|
13
|
+
)
|
|
14
|
+
self.error_code = "composition_conflict"
|
|
15
|
+
self.symbol = symbol
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def merge_python_overlay(
|
|
19
|
+
*,
|
|
20
|
+
ancestor: Path,
|
|
21
|
+
current: Path,
|
|
22
|
+
other: Path,
|
|
23
|
+
relative_path: str,
|
|
24
|
+
) -> None:
|
|
25
|
+
if not relative_path.endswith(".py"):
|
|
26
|
+
raise ValueError("Python driver only supports .py paths")
|
|
27
|
+
|
|
28
|
+
ancestor_tree = ast.parse(ancestor.read_text())
|
|
29
|
+
current_tree = ast.parse(current.read_text())
|
|
30
|
+
other_tree = ast.parse(other.read_text())
|
|
31
|
+
|
|
32
|
+
ancestor_imports, ancestor_defs = _split_nodes(ancestor_tree)
|
|
33
|
+
current_imports, current_defs = _split_nodes(current_tree)
|
|
34
|
+
other_imports, other_defs = _split_nodes(other_tree)
|
|
35
|
+
|
|
36
|
+
merged_imports = _union_imports(current_imports, other_imports)
|
|
37
|
+
merged_defs = _merge_definitions(ancestor_defs, current_defs, other_defs)
|
|
38
|
+
|
|
39
|
+
merged_module = ast.Module(body=merged_imports + merged_defs, type_ignores=[])
|
|
40
|
+
ast.fix_missing_locations(merged_module)
|
|
41
|
+
current.write_text(ast.unparse(merged_module) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split_nodes(
|
|
45
|
+
tree: ast.Module,
|
|
46
|
+
) -> tuple[list[ast.stmt], dict[str, ast.stmt]]:
|
|
47
|
+
imports: list[ast.stmt] = []
|
|
48
|
+
defs: dict[str, ast.stmt] = {}
|
|
49
|
+
for node in tree.body:
|
|
50
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
51
|
+
imports.append(node)
|
|
52
|
+
else:
|
|
53
|
+
name = _node_name(node)
|
|
54
|
+
if name:
|
|
55
|
+
defs[name] = node
|
|
56
|
+
return imports, defs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _node_name(node: ast.stmt) -> str | None:
|
|
60
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
61
|
+
return node.name
|
|
62
|
+
if isinstance(node, ast.Assign):
|
|
63
|
+
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
64
|
+
return node.targets[0].id
|
|
65
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
66
|
+
return node.target.id
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _union_imports(
|
|
71
|
+
current: list[ast.stmt], other: list[ast.stmt]
|
|
72
|
+
) -> list[ast.stmt]:
|
|
73
|
+
seen: set[str] = set()
|
|
74
|
+
result: list[ast.stmt] = []
|
|
75
|
+
for node in current + other:
|
|
76
|
+
key = ast.dump(node)
|
|
77
|
+
if key not in seen:
|
|
78
|
+
seen.add(key)
|
|
79
|
+
result.append(node)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _merge_definitions(
|
|
84
|
+
ancestor: dict[str, ast.stmt],
|
|
85
|
+
current: dict[str, ast.stmt],
|
|
86
|
+
other: dict[str, ast.stmt],
|
|
87
|
+
) -> list[ast.stmt]:
|
|
88
|
+
all_names = list(dict.fromkeys(list(current.keys()) + list(other.keys())))
|
|
89
|
+
result: list[ast.stmt] = []
|
|
90
|
+
|
|
91
|
+
for name in all_names:
|
|
92
|
+
a_node = ancestor.get(name)
|
|
93
|
+
c_node = current.get(name)
|
|
94
|
+
o_node = other.get(name)
|
|
95
|
+
|
|
96
|
+
a_dump = ast.dump(a_node) if a_node else None
|
|
97
|
+
c_dump = ast.dump(c_node) if c_node else None
|
|
98
|
+
o_dump = ast.dump(o_node) if o_node else None
|
|
99
|
+
|
|
100
|
+
current_changed = c_dump != a_dump
|
|
101
|
+
other_changed = o_dump != a_dump
|
|
102
|
+
|
|
103
|
+
if current_changed and other_changed and c_node and o_node:
|
|
104
|
+
if isinstance(o_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
105
|
+
result.append(o_node)
|
|
106
|
+
else:
|
|
107
|
+
raise PythonCompositionConflict(name)
|
|
108
|
+
elif o_node and other_changed:
|
|
109
|
+
result.append(o_node)
|
|
110
|
+
elif c_node:
|
|
111
|
+
result.append(c_node)
|
|
112
|
+
elif o_node:
|
|
113
|
+
result.append(o_node)
|
|
114
|
+
|
|
115
|
+
return result
|