worktree-env 0.2.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.
- git_worktree_env/__init__.py +3 -0
- git_worktree_env/__main__.py +7 -0
- git_worktree_env/cli.py +233 -0
- git_worktree_env/config.py +127 -0
- git_worktree_env/hooks.py +217 -0
- git_worktree_env/paths.py +64 -0
- git_worktree_env/profiles.py +215 -0
- git_worktree_env/projector.py +166 -0
- git_worktree_env/reconciler.py +273 -0
- git_worktree_env/registry.py +166 -0
- git_worktree_env/utils.py +82 -0
- worktree_env-0.2.0.dist-info/METADATA +330 -0
- worktree_env-0.2.0.dist-info/RECORD +16 -0
- worktree_env-0.2.0.dist-info/WHEEL +4 -0
- worktree_env-0.2.0.dist-info/entry_points.txt +3 -0
- worktree_env-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Project-profile loading, validation, and worktree matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from string import Template
|
|
9
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .paths import AppPaths
|
|
14
|
+
from .utils import WteError, expand_home, run_git
|
|
15
|
+
|
|
16
|
+
Profile = Dict[str, Any]
|
|
17
|
+
PORT_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_profile(path: Path) -> Profile:
|
|
21
|
+
"""Parse one YAML or JSON profile and attach its source path."""
|
|
22
|
+
try:
|
|
23
|
+
if path.suffix.lower() in (".yaml", ".yml"):
|
|
24
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
25
|
+
else:
|
|
26
|
+
raw = json.loads(path.read_text() or "{}")
|
|
27
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
28
|
+
raise WteError(f"cannot parse profile {path}: {exc}") from exc
|
|
29
|
+
if not isinstance(raw, dict):
|
|
30
|
+
raise WteError(f"profile root must be a mapping: {path}")
|
|
31
|
+
raw["_file"] = str(path)
|
|
32
|
+
return raw
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_profiles(paths: AppPaths, strict: bool = True) -> List[Profile]:
|
|
36
|
+
"""Load profiles, preferring YAML when files share the same stem."""
|
|
37
|
+
if not paths.profiles.is_dir():
|
|
38
|
+
return []
|
|
39
|
+
rank = {".yaml": 0, ".yml": 1, ".json": 2}
|
|
40
|
+
files: List[Path] = []
|
|
41
|
+
for pattern in ("*.yaml", "*.yml", "*.json"):
|
|
42
|
+
files.extend(paths.profiles.glob(pattern))
|
|
43
|
+
files = [path for path in files if path.resolve() != paths.config.resolve()]
|
|
44
|
+
|
|
45
|
+
profiles: List[Profile] = []
|
|
46
|
+
seen = set()
|
|
47
|
+
for path in sorted(files, key=lambda item: (item.stem, rank[item.suffix.lower()])):
|
|
48
|
+
if path.stem in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(path.stem)
|
|
51
|
+
try:
|
|
52
|
+
profile = parse_profile(path)
|
|
53
|
+
except WteError:
|
|
54
|
+
if strict:
|
|
55
|
+
raise
|
|
56
|
+
continue
|
|
57
|
+
if profile.get("name"):
|
|
58
|
+
profiles.append(profile)
|
|
59
|
+
elif strict:
|
|
60
|
+
raise WteError(f"profile has no name: {path}")
|
|
61
|
+
return profiles
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def worktree_root(cwd: Optional[Path] = None) -> Path:
|
|
65
|
+
"""Return the root of the Git worktree containing ``cwd``."""
|
|
66
|
+
return Path(run_git("rev-parse", "--show-toplevel", cwd=cwd)).resolve()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main_worktree_root(root: Path) -> Path:
|
|
70
|
+
"""Return the main worktree associated with ``root``.
|
|
71
|
+
|
|
72
|
+
Normal and linked worktrees share the main worktree's ``.git`` directory.
|
|
73
|
+
The porcelain listing is a fallback for repositories whose common Git
|
|
74
|
+
directory uses a non-standard location.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
common = Path(run_git("rev-parse", "--git-common-dir", cwd=root))
|
|
78
|
+
except WteError:
|
|
79
|
+
return root.resolve()
|
|
80
|
+
common = (root / common).resolve() if not common.is_absolute() else common.resolve()
|
|
81
|
+
if common.name == ".git":
|
|
82
|
+
return common.parent
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
listing = run_git("worktree", "list", "--porcelain", cwd=root)
|
|
86
|
+
first = next(
|
|
87
|
+
line[len("worktree ") :]
|
|
88
|
+
for line in listing.splitlines()
|
|
89
|
+
if line.startswith("worktree ")
|
|
90
|
+
)
|
|
91
|
+
return Path(first).resolve()
|
|
92
|
+
except (WteError, StopIteration):
|
|
93
|
+
return root.resolve()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def configured_main_worktree(profile: Profile) -> Optional[Path]:
|
|
97
|
+
"""Return the normalized main-worktree path configured by a profile."""
|
|
98
|
+
match = profile.get("match") or {}
|
|
99
|
+
raw = match.get("main_worktree") if isinstance(match, dict) else None
|
|
100
|
+
if not raw:
|
|
101
|
+
return None
|
|
102
|
+
return expand_home(str(raw)).resolve()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_profile(paths: AppPaths, root: Path) -> Optional[Profile]:
|
|
106
|
+
"""Find the unique profile whose main worktree owns ``root``."""
|
|
107
|
+
actual = main_worktree_root(root)
|
|
108
|
+
hits = [
|
|
109
|
+
profile
|
|
110
|
+
for profile in load_profiles(paths)
|
|
111
|
+
if configured_main_worktree(profile) == actual
|
|
112
|
+
]
|
|
113
|
+
if not hits:
|
|
114
|
+
return None
|
|
115
|
+
if len(hits) > 1:
|
|
116
|
+
sources = ", ".join(Path(item["_file"]).name for item in hits)
|
|
117
|
+
raise WteError(f"multiple profiles match main worktree {actual}: {sources}")
|
|
118
|
+
return hits[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _port_claims(profile: Profile) -> Sequence[Dict[str, Any]]:
|
|
122
|
+
raw = profile.get("ports") or profile.get("services") or []
|
|
123
|
+
return raw if isinstance(raw, list) else []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_profiles(paths: AppPaths) -> Tuple[List[str], List[str]]:
|
|
127
|
+
"""Validate all profiles without requiring their worktrees to exist."""
|
|
128
|
+
errors: List[str] = []
|
|
129
|
+
warnings: List[str] = []
|
|
130
|
+
try:
|
|
131
|
+
profiles = load_profiles(paths)
|
|
132
|
+
except WteError as exc:
|
|
133
|
+
return [str(exc)], warnings
|
|
134
|
+
|
|
135
|
+
names: Dict[str, Path] = {}
|
|
136
|
+
main_roots: Dict[Path, Path] = {}
|
|
137
|
+
for profile in profiles:
|
|
138
|
+
source = Path(profile["_file"])
|
|
139
|
+
label = source.name
|
|
140
|
+
name = profile.get("name")
|
|
141
|
+
if not isinstance(name, str) or not name.strip():
|
|
142
|
+
errors.append(f"{label}: name must be a non-empty string")
|
|
143
|
+
elif name in names:
|
|
144
|
+
errors.append(f"{label}: duplicate profile name {name!r} (also in {names[name].name})")
|
|
145
|
+
else:
|
|
146
|
+
names[name] = source
|
|
147
|
+
|
|
148
|
+
match = profile.get("match") or {}
|
|
149
|
+
main_raw = match.get("main_worktree") if isinstance(match, dict) else None
|
|
150
|
+
main_root = configured_main_worktree(profile)
|
|
151
|
+
if main_root is None:
|
|
152
|
+
errors.append(f"{label}: match.main_worktree is required")
|
|
153
|
+
elif not expand_home(str(main_raw)).is_absolute():
|
|
154
|
+
errors.append(f"{label}: match.main_worktree must be an absolute or home-relative path")
|
|
155
|
+
elif main_root in main_roots:
|
|
156
|
+
errors.append(
|
|
157
|
+
f"{label}: main worktree is also configured by {main_roots[main_root].name}"
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
main_roots[main_root] = source
|
|
161
|
+
if not main_root.exists():
|
|
162
|
+
warnings.append(f"{label}: main worktree does not exist: {main_root}")
|
|
163
|
+
|
|
164
|
+
claims = _port_claims(profile)
|
|
165
|
+
if not claims:
|
|
166
|
+
errors.append(f"{label}: ports must contain at least one claim")
|
|
167
|
+
continue
|
|
168
|
+
ids: List[str] = []
|
|
169
|
+
for index, claim in enumerate(claims):
|
|
170
|
+
port_id = claim.get("id") if isinstance(claim, dict) else None
|
|
171
|
+
if not isinstance(port_id, str) or not PORT_ID.match(port_id):
|
|
172
|
+
errors.append(f"{label}: ports[{index}].id is invalid")
|
|
173
|
+
elif port_id in ids:
|
|
174
|
+
errors.append(f"{label}: duplicate port id {port_id!r}")
|
|
175
|
+
else:
|
|
176
|
+
ids.append(port_id)
|
|
177
|
+
|
|
178
|
+
mapping = {port_id: "0" for port_id in ids}
|
|
179
|
+
writes = profile.get("writes") or []
|
|
180
|
+
if not isinstance(writes, list):
|
|
181
|
+
errors.append(f"{label}: writes must be a list")
|
|
182
|
+
continue
|
|
183
|
+
for index, spec in enumerate(writes):
|
|
184
|
+
if not isinstance(spec, dict):
|
|
185
|
+
errors.append(f"{label}: writes[{index}] must be a mapping")
|
|
186
|
+
continue
|
|
187
|
+
write_path = spec.get("path")
|
|
188
|
+
if not write_path:
|
|
189
|
+
errors.append(f"{label}: writes[{index}].path is required")
|
|
190
|
+
elif Path(str(write_path)).is_absolute() or ".." in Path(str(write_path)).parts:
|
|
191
|
+
errors.append(f"{label}: writes[{index}].path must stay inside the worktree")
|
|
192
|
+
try:
|
|
193
|
+
Template(str(spec.get("body") or "")).substitute(mapping)
|
|
194
|
+
except (KeyError, ValueError) as exc:
|
|
195
|
+
errors.append(f"{label}: writes[{index}].body has an invalid variable: {exc}")
|
|
196
|
+
|
|
197
|
+
secrets = profile.get("secrets") or []
|
|
198
|
+
if not isinstance(secrets, list):
|
|
199
|
+
errors.append(f"{label}: secrets must be a list")
|
|
200
|
+
else:
|
|
201
|
+
for index, entry in enumerate(secrets):
|
|
202
|
+
if not isinstance(entry, dict):
|
|
203
|
+
errors.append(f"{label}: secrets[{index}] must be a mapping")
|
|
204
|
+
continue
|
|
205
|
+
if not entry.get("source"):
|
|
206
|
+
errors.append(f"{label}: secrets[{index}].source is required")
|
|
207
|
+
target = entry.get("target")
|
|
208
|
+
if not target:
|
|
209
|
+
errors.append(f"{label}: secrets[{index}].target is required")
|
|
210
|
+
elif Path(str(target)).is_absolute() or ".." in Path(str(target)).parts:
|
|
211
|
+
errors.append(f"{label}: secrets[{index}].target must stay inside the worktree")
|
|
212
|
+
|
|
213
|
+
if not profiles:
|
|
214
|
+
warnings.append(f"no profiles found in {paths.profiles}")
|
|
215
|
+
return errors, warnings
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Projection of ports, local secrets, and setup commands into a worktree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from string import Template
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .config import load_port_pool
|
|
14
|
+
from .paths import AppPaths
|
|
15
|
+
from .profiles import Profile, find_profile
|
|
16
|
+
from .registry import (
|
|
17
|
+
allocate_ports,
|
|
18
|
+
load_registry,
|
|
19
|
+
prune_registry,
|
|
20
|
+
registry_lock,
|
|
21
|
+
save_registry,
|
|
22
|
+
)
|
|
23
|
+
from .utils import WteError, expand_profile_path, log, safe_worktree_target
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ApplyResult:
|
|
27
|
+
"""Details of a successful profile projection."""
|
|
28
|
+
|
|
29
|
+
profile: str
|
|
30
|
+
root: Path
|
|
31
|
+
ports: Dict[str, int]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_template(body: str, ports: Dict[str, int]) -> str:
|
|
35
|
+
"""Replace profile port variables such as ``${frontend}``."""
|
|
36
|
+
try:
|
|
37
|
+
return Template(body).substitute({key: str(value) for key, value in ports.items()})
|
|
38
|
+
except (KeyError, ValueError) as exc:
|
|
39
|
+
raise WteError(f"cannot render port template: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _secret_entries(profile: Profile) -> List[Dict[str, Any]]:
|
|
43
|
+
raw = profile.get("secrets") or []
|
|
44
|
+
return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def apply_secrets(profile: Profile, root: Path) -> None:
|
|
48
|
+
"""Replace configured targets with symlinks to existing local secret files."""
|
|
49
|
+
for entry in _secret_entries(profile):
|
|
50
|
+
source_raw = str(entry.get("source") or "").strip()
|
|
51
|
+
target_raw = str(entry.get("target") or "").strip()
|
|
52
|
+
if not source_raw or not target_raw:
|
|
53
|
+
continue
|
|
54
|
+
source = expand_profile_path(source_raw, profile).resolve()
|
|
55
|
+
target = safe_worktree_target(root, target_raw)
|
|
56
|
+
if not source.is_file():
|
|
57
|
+
log(f"secret skipped; source does not exist: {source}")
|
|
58
|
+
continue
|
|
59
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
if target.is_symlink() or target.exists():
|
|
61
|
+
if target.is_dir() and not target.is_symlink():
|
|
62
|
+
raise WteError(f"secret target is a directory: {target}")
|
|
63
|
+
target.unlink()
|
|
64
|
+
target.symlink_to(source)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_writes(profile: Profile, root: Path, ports: Dict[str, int]) -> None:
|
|
68
|
+
"""Render complete generated files declared by the profile."""
|
|
69
|
+
writes = profile.get("writes") or []
|
|
70
|
+
if not isinstance(writes, list):
|
|
71
|
+
raise WteError(f"profile {profile.get('name')} has an invalid writes section")
|
|
72
|
+
for spec in writes:
|
|
73
|
+
if not isinstance(spec, dict) or not spec.get("path"):
|
|
74
|
+
raise WteError(f"profile {profile.get('name')} has an invalid write entry")
|
|
75
|
+
target = safe_worktree_target(root, str(spec["path"]))
|
|
76
|
+
body = render_template(str(spec.get("body") or ""), ports)
|
|
77
|
+
if target.is_symlink():
|
|
78
|
+
target.unlink()
|
|
79
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
header = str(spec.get("header") or "# Generated by wte; changes will be overwritten.\n")
|
|
81
|
+
payload = header + body
|
|
82
|
+
target.write_text(payload if payload.endswith("\n") else payload + "\n")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _init_entries(profile: Profile) -> List[Dict[str, Any]]:
|
|
86
|
+
raw = profile.get("init") or []
|
|
87
|
+
return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_initializers(profile: Profile, root: Path) -> None:
|
|
91
|
+
"""Start missing dependency setup tasks without blocking Git checkout."""
|
|
92
|
+
for index, item in enumerate(_init_entries(profile)):
|
|
93
|
+
command = str(item.get("command") or "").strip()
|
|
94
|
+
cwd_raw = str(item.get("cwd") or "").strip()
|
|
95
|
+
if not command or not cwd_raw:
|
|
96
|
+
log(f"initializer skipped; command and cwd are required: {item}")
|
|
97
|
+
continue
|
|
98
|
+
cwd = expand_profile_path(cwd_raw, profile, root=root).resolve()
|
|
99
|
+
if not cwd.is_dir():
|
|
100
|
+
log(f"initializer skipped; directory does not exist: {cwd}")
|
|
101
|
+
continue
|
|
102
|
+
skip_if = item.get("skip_if")
|
|
103
|
+
if skip_if:
|
|
104
|
+
marker = expand_profile_path(str(skip_if), profile, root=cwd)
|
|
105
|
+
if marker.exists():
|
|
106
|
+
continue
|
|
107
|
+
log_path = Path(tempfile.gettempdir()) / f"wte-init-{os.getpid()}-{index}.log"
|
|
108
|
+
output = log_path.open("a")
|
|
109
|
+
try:
|
|
110
|
+
subprocess.Popen(
|
|
111
|
+
["bash", "-lc", command],
|
|
112
|
+
cwd=str(cwd),
|
|
113
|
+
stdout=output,
|
|
114
|
+
stderr=subprocess.STDOUT,
|
|
115
|
+
start_new_session=True,
|
|
116
|
+
)
|
|
117
|
+
finally:
|
|
118
|
+
output.close()
|
|
119
|
+
log(f"initializer started: {command} -> {log_path}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def apply_worktree(
|
|
123
|
+
paths: AppPaths,
|
|
124
|
+
root: Path,
|
|
125
|
+
setup: bool = False,
|
|
126
|
+
) -> Optional[ApplyResult]:
|
|
127
|
+
"""Apply the matching profile to one worktree.
|
|
128
|
+
|
|
129
|
+
An unmatched repository is intentionally a no-op so the global checkout
|
|
130
|
+
hook remains safe for every Git repository on the machine.
|
|
131
|
+
"""
|
|
132
|
+
root = root.resolve()
|
|
133
|
+
profile = find_profile(paths, root)
|
|
134
|
+
if profile is None:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
pool = load_port_pool(paths)
|
|
138
|
+
with registry_lock(paths):
|
|
139
|
+
registry = prune_registry(load_registry(paths))
|
|
140
|
+
block_start, ports = allocate_ports(profile, root, registry, pool)
|
|
141
|
+
|
|
142
|
+
# Keep allocation and projection in one short transaction. If writing
|
|
143
|
+
# fails, no registry entry is committed and the reconciler can retry.
|
|
144
|
+
apply_secrets(profile, root)
|
|
145
|
+
apply_writes(profile, root, ports)
|
|
146
|
+
registry[str(root)] = {
|
|
147
|
+
"profile": profile["name"],
|
|
148
|
+
"file": Path(profile.get("_file") or "").name,
|
|
149
|
+
"block_start": block_start,
|
|
150
|
+
"ports": ports,
|
|
151
|
+
}
|
|
152
|
+
save_registry(paths, registry)
|
|
153
|
+
|
|
154
|
+
if setup:
|
|
155
|
+
run_initializers(profile, root)
|
|
156
|
+
return ApplyResult(str(profile["name"]), root, ports)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def print_apply_result(result: ApplyResult) -> None:
|
|
160
|
+
"""Print a compact, stable summary for interactive use and hook logs."""
|
|
161
|
+
endpoints = " ".join(
|
|
162
|
+
f"{name}=http://127.0.0.1:{port}" for name, port in result.ports.items()
|
|
163
|
+
)
|
|
164
|
+
print(f"[wte] profile={result.profile}")
|
|
165
|
+
print(f"[wte] root={result.root}")
|
|
166
|
+
print(f"[wte] {endpoints}")
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Host-side reconciliation for worktrees created without Git hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Sequence, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from .hooks import resolve_wte_executable
|
|
15
|
+
from .paths import AppPaths
|
|
16
|
+
from .profiles import configured_main_worktree, load_profiles, worktree_root
|
|
17
|
+
from .projector import apply_worktree, print_apply_result
|
|
18
|
+
from .registry import load_registry, prune_registry
|
|
19
|
+
from .utils import WteError, log, run_git
|
|
20
|
+
|
|
21
|
+
LAUNCHD_LABEL = "io.github.archcst.wte-reconciler"
|
|
22
|
+
SYSTEMD_SERVICE = "wte-reconciler.service"
|
|
23
|
+
SYSTEMD_PATH = "wte-reconciler.path"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class MonitorStatus:
|
|
28
|
+
"""Installation and runtime state of the host filesystem monitor."""
|
|
29
|
+
|
|
30
|
+
supported: bool
|
|
31
|
+
installed: bool
|
|
32
|
+
active: bool
|
|
33
|
+
watch_paths: Tuple[Path, ...]
|
|
34
|
+
detail: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ReconcileResult:
|
|
39
|
+
"""Summary of one scan across all configured Git repositories."""
|
|
40
|
+
|
|
41
|
+
discovered: int
|
|
42
|
+
applied: int
|
|
43
|
+
pending: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run(command: Sequence[str]) -> subprocess.CompletedProcess:
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
list(command),
|
|
49
|
+
stdout=subprocess.PIPE,
|
|
50
|
+
stderr=subprocess.PIPE,
|
|
51
|
+
universal_newlines=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _common_git_dir(main: Path) -> Optional[Path]:
|
|
56
|
+
try:
|
|
57
|
+
raw = Path(run_git("rev-parse", "--git-common-dir", cwd=main))
|
|
58
|
+
except WteError:
|
|
59
|
+
return None
|
|
60
|
+
return (main / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def discover_watch_paths(paths: AppPaths) -> Tuple[Path, ...]:
|
|
64
|
+
"""Return each configured repository's linked-worktree metadata directory."""
|
|
65
|
+
watched: Set[Path] = set()
|
|
66
|
+
for profile in load_profiles(paths, strict=False):
|
|
67
|
+
main = configured_main_worktree(profile)
|
|
68
|
+
if main is None or not main.is_dir():
|
|
69
|
+
continue
|
|
70
|
+
common = _common_git_dir(main)
|
|
71
|
+
if common is not None:
|
|
72
|
+
watched.add(common / "worktrees")
|
|
73
|
+
return tuple(sorted(watched))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _listed_worktrees(main: Path) -> List[Path]:
|
|
77
|
+
"""Read worktree paths from Git's NUL-delimited porcelain output."""
|
|
78
|
+
output = run_git("worktree", "list", "--porcelain", "-z", cwd=main)
|
|
79
|
+
roots: List[Path] = []
|
|
80
|
+
for field in output.split("\0"):
|
|
81
|
+
if field.startswith("worktree "):
|
|
82
|
+
roots.append(Path(field[len("worktree ") :]).resolve())
|
|
83
|
+
return roots
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _candidate_worktrees(paths: AppPaths) -> Set[Path]:
|
|
87
|
+
candidates: Set[Path] = set()
|
|
88
|
+
for profile in load_profiles(paths):
|
|
89
|
+
main = configured_main_worktree(profile)
|
|
90
|
+
if main is None or not main.is_dir():
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
candidates.update(_listed_worktrees(main))
|
|
94
|
+
except WteError as exc:
|
|
95
|
+
log(f"reconcile skipped repository {main}: {exc}")
|
|
96
|
+
return candidates
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def reconcile_once(paths: AppPaths) -> ReconcileResult:
|
|
100
|
+
"""Project configuration into worktrees missing from the port registry."""
|
|
101
|
+
registered = set(prune_registry(load_registry(paths)))
|
|
102
|
+
candidates = _candidate_worktrees(paths)
|
|
103
|
+
applied = 0
|
|
104
|
+
pending = 0
|
|
105
|
+
|
|
106
|
+
for candidate in sorted(candidates):
|
|
107
|
+
if str(candidate) in registered:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
# Git may publish common-dir metadata before checkout is complete.
|
|
111
|
+
if worktree_root(candidate) != candidate:
|
|
112
|
+
raise WteError(f"worktree root is not stable yet: {candidate}")
|
|
113
|
+
result = apply_worktree(paths, candidate, setup=False)
|
|
114
|
+
if result is None:
|
|
115
|
+
continue
|
|
116
|
+
print_apply_result(result)
|
|
117
|
+
registered.add(str(candidate))
|
|
118
|
+
applied += 1
|
|
119
|
+
except (OSError, WteError) as exc:
|
|
120
|
+
pending += 1
|
|
121
|
+
log(f"reconcile will retry {candidate}: {exc}")
|
|
122
|
+
|
|
123
|
+
return ReconcileResult(len(candidates), applied, pending)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reconcile_with_retry(paths: AppPaths) -> ReconcileResult:
|
|
127
|
+
"""Retry briefly while a newly announced checkout becomes readable."""
|
|
128
|
+
result = reconcile_once(paths)
|
|
129
|
+
for delay in (0.5, 1.0, 2.0):
|
|
130
|
+
if result.pending == 0:
|
|
131
|
+
break
|
|
132
|
+
time.sleep(delay)
|
|
133
|
+
result = reconcile_once(paths)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _launch_agent_path() -> Path:
|
|
138
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LAUNCHD_LABEL}.plist"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _launch_domain() -> str:
|
|
142
|
+
return f"gui/{os.getuid()}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write_launch_agent(paths: AppPaths, executable: Path, watched: Tuple[Path, ...]) -> Path:
|
|
146
|
+
agent = _launch_agent_path()
|
|
147
|
+
agent.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
payload = {
|
|
149
|
+
"Label": LAUNCHD_LABEL,
|
|
150
|
+
"ProgramArguments": [str(executable), "_reconcile"],
|
|
151
|
+
"RunAtLoad": True,
|
|
152
|
+
"WatchPaths": [str(path) for path in watched],
|
|
153
|
+
"ProcessType": "Background",
|
|
154
|
+
"ThrottleInterval": 2,
|
|
155
|
+
"StandardOutPath": str(paths.state / "reconciler.log"),
|
|
156
|
+
"StandardErrorPath": str(paths.state / "reconciler.log"),
|
|
157
|
+
}
|
|
158
|
+
with agent.open("wb") as handle:
|
|
159
|
+
plistlib.dump(payload, handle, sort_keys=True)
|
|
160
|
+
return agent
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _install_launchd(paths: AppPaths, executable: Path, watched: Tuple[Path, ...]) -> MonitorStatus:
|
|
164
|
+
agent = _write_launch_agent(paths, executable, watched)
|
|
165
|
+
_run(["launchctl", "bootout", f"{_launch_domain()}/{LAUNCHD_LABEL}"])
|
|
166
|
+
result = _run(["launchctl", "bootstrap", _launch_domain(), str(agent)])
|
|
167
|
+
if result.returncode != 0:
|
|
168
|
+
raise WteError(result.stderr.strip() or "launchctl bootstrap failed")
|
|
169
|
+
return monitor_status(paths, watched)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _systemd_user_dir() -> Path:
|
|
173
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
174
|
+
base = Path(os.path.expanduser(xdg)) if xdg else Path.home() / ".config"
|
|
175
|
+
return base / "systemd" / "user"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_systemd_units(executable: Path, watched: Tuple[Path, ...]) -> Tuple[Path, Path]:
|
|
179
|
+
unit_dir = _systemd_user_dir()
|
|
180
|
+
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
service = unit_dir / SYSTEMD_SERVICE
|
|
182
|
+
path_unit = unit_dir / SYSTEMD_PATH
|
|
183
|
+
escaped_executable = str(executable).replace("%", "%%")
|
|
184
|
+
service.write_text(
|
|
185
|
+
"[Unit]\nDescription=Reconcile worktree-env projects\n\n"
|
|
186
|
+
"[Service]\nType=oneshot\n"
|
|
187
|
+
f'ExecStart="{escaped_executable}" _reconcile\n'
|
|
188
|
+
)
|
|
189
|
+
path_lines = "\n".join(
|
|
190
|
+
f"PathChanged={str(path).replace('%', '%%')}" for path in watched
|
|
191
|
+
)
|
|
192
|
+
path_unit.write_text(
|
|
193
|
+
"[Unit]\nDescription=Watch Git linked-worktree metadata for wte\n\n"
|
|
194
|
+
f"[Path]\n{path_lines}\nUnit={SYSTEMD_SERVICE}\n\n"
|
|
195
|
+
"[Install]\nWantedBy=default.target\n"
|
|
196
|
+
)
|
|
197
|
+
return service, path_unit
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _install_systemd(paths: AppPaths, executable: Path, watched: Tuple[Path, ...]) -> MonitorStatus:
|
|
201
|
+
_write_systemd_units(executable, watched)
|
|
202
|
+
reload_result = _run(["systemctl", "--user", "daemon-reload"])
|
|
203
|
+
if reload_result.returncode != 0:
|
|
204
|
+
raise WteError(reload_result.stderr.strip() or "systemd user manager is unavailable")
|
|
205
|
+
result = _run(["systemctl", "--user", "enable", "--now", SYSTEMD_PATH])
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
raise WteError(result.stderr.strip() or "cannot enable wte-reconciler.path")
|
|
208
|
+
return monitor_status(paths, watched)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def install_monitor(paths: AppPaths) -> MonitorStatus:
|
|
212
|
+
"""Install or refresh the OS-managed directory monitor for current profiles."""
|
|
213
|
+
watched = discover_watch_paths(paths)
|
|
214
|
+
if not watched:
|
|
215
|
+
uninstall_monitor(paths)
|
|
216
|
+
return MonitorStatus(
|
|
217
|
+
True,
|
|
218
|
+
False,
|
|
219
|
+
False,
|
|
220
|
+
(),
|
|
221
|
+
"no configured main worktrees; add a profile and run `wte monitor enable`",
|
|
222
|
+
)
|
|
223
|
+
executable = resolve_wte_executable()
|
|
224
|
+
system = platform.system()
|
|
225
|
+
if system == "Darwin":
|
|
226
|
+
return _install_launchd(paths, executable, watched)
|
|
227
|
+
if system == "Linux":
|
|
228
|
+
return _install_systemd(paths, executable, watched)
|
|
229
|
+
return MonitorStatus(False, False, False, watched, f"unsupported platform: {system}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _uninstall_launchd() -> None:
|
|
233
|
+
_run(["launchctl", "bootout", f"{_launch_domain()}/{LAUNCHD_LABEL}"])
|
|
234
|
+
agent = _launch_agent_path()
|
|
235
|
+
if agent.exists():
|
|
236
|
+
agent.unlink()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _uninstall_systemd() -> None:
|
|
240
|
+
_run(["systemctl", "--user", "disable", "--now", SYSTEMD_PATH])
|
|
241
|
+
unit_dir = _systemd_user_dir()
|
|
242
|
+
for name in (SYSTEMD_PATH, SYSTEMD_SERVICE):
|
|
243
|
+
unit = unit_dir / name
|
|
244
|
+
if unit.exists():
|
|
245
|
+
unit.unlink()
|
|
246
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def uninstall_monitor(paths: AppPaths) -> None:
|
|
250
|
+
"""Remove any platform-specific reconciler registration."""
|
|
251
|
+
system = platform.system()
|
|
252
|
+
if system == "Darwin":
|
|
253
|
+
_uninstall_launchd()
|
|
254
|
+
elif system == "Linux":
|
|
255
|
+
_uninstall_systemd()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def monitor_status(
|
|
259
|
+
paths: AppPaths,
|
|
260
|
+
watched: Optional[Tuple[Path, ...]] = None,
|
|
261
|
+
) -> MonitorStatus:
|
|
262
|
+
"""Inspect the current platform's reconciler registration."""
|
|
263
|
+
watched = watched if watched is not None else discover_watch_paths(paths)
|
|
264
|
+
system = platform.system()
|
|
265
|
+
if system == "Darwin":
|
|
266
|
+
installed = _launch_agent_path().is_file()
|
|
267
|
+
active = _run(["launchctl", "print", f"{_launch_domain()}/{LAUNCHD_LABEL}"]).returncode == 0
|
|
268
|
+
return MonitorStatus(True, installed, active, watched, "launchd")
|
|
269
|
+
if system == "Linux":
|
|
270
|
+
path_unit = _systemd_user_dir() / SYSTEMD_PATH
|
|
271
|
+
active = _run(["systemctl", "--user", "is-active", "--quiet", SYSTEMD_PATH]).returncode == 0
|
|
272
|
+
return MonitorStatus(True, path_unit.is_file(), active, watched, "systemd.path")
|
|
273
|
+
return MonitorStatus(False, False, False, watched, f"unsupported platform: {system}")
|