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.
@@ -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}")