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,3 @@
1
+ """worktree-env public package."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m git_worktree_env`` to behave like ``wte``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,233 @@
1
+ """Command-line interface for wte."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from importlib import metadata
9
+ from typing import Optional, Sequence
10
+
11
+ from . import __version__
12
+ from .config import initialize_config, initialize_profile_template, load_port_pool
13
+ from .hooks import hooks_status, install_hooks, uninstall_hooks
14
+ from .paths import AppPaths
15
+ from .profiles import load_profiles, validate_profiles, worktree_root
16
+ from .projector import apply_worktree, print_apply_result
17
+ from .reconciler import (
18
+ install_monitor,
19
+ monitor_status,
20
+ reconcile_with_retry,
21
+ uninstall_monitor,
22
+ )
23
+ from .registry import load_registry, prune_registry
24
+ from .utils import WteError, expand_profile_path, log, run_git
25
+
26
+ INTERNAL_HOOK_COMMAND = "_hook"
27
+ INTERNAL_RECONCILE_COMMAND = "_reconcile"
28
+ LEGACY_DISTRIBUTION = "git-worktree-env"
29
+
30
+
31
+ def _warn_if_legacy_distribution_installed() -> None:
32
+ """Tell users of the transition package how to adopt the new package name."""
33
+ try:
34
+ metadata.distribution(LEGACY_DISTRIBUTION)
35
+ except metadata.PackageNotFoundError:
36
+ return
37
+
38
+ print(
39
+ "[wte] warning: git-worktree-env has been renamed to worktree-env.\n"
40
+ "[wte] migrate: uv tool uninstall git-worktree-env && "
41
+ "uv tool install worktree-env\n"
42
+ "[wte] then run 'wte init' to refresh the Git hook; refresh the monitor "
43
+ "with 'wte monitor enable' if used.\n"
44
+ "[wte] your existing ~/.config/wte configuration will be preserved.",
45
+ file=sys.stderr,
46
+ )
47
+
48
+
49
+ def _build_parser() -> argparse.ArgumentParser:
50
+ """Build the public CLI without exposing internal integration commands."""
51
+ parser = argparse.ArgumentParser(
52
+ prog="wte",
53
+ description="Per-worktree ports, secrets, and local environment setup.",
54
+ )
55
+ parser.add_argument("--version", action="version", version=f"wte {__version__}")
56
+ commands = parser.add_subparsers(dest="command", required=True)
57
+
58
+ commands.add_parser(
59
+ "init",
60
+ help="create local configuration and install the Git hook dispatcher",
61
+ )
62
+ commands.add_parser("sync", help="synchronize the current worktree")
63
+ commands.add_parser("list", help="list live worktree port allocations")
64
+ commands.add_parser("doctor", help="diagnose configuration and host integration")
65
+ monitor_parser = commands.add_parser(
66
+ "monitor",
67
+ help="manage optional host monitoring for sandbox-created worktrees",
68
+ )
69
+ monitor_commands = monitor_parser.add_subparsers(dest="monitor_command", required=True)
70
+ monitor_commands.add_parser("enable", help="install or refresh host monitoring")
71
+ monitor_commands.add_parser("disable", help="remove host monitoring")
72
+ commands.add_parser(
73
+ "uninstall",
74
+ help="remove Git hooks while preserving configuration and state",
75
+ )
76
+ return parser
77
+
78
+
79
+ def _cmd_init(paths: AppPaths) -> int:
80
+ config_created = initialize_config(paths)
81
+ template, template_created = initialize_profile_template(paths)
82
+ dispatcher = install_hooks(paths)
83
+ print(f"[wte] config: {paths.config} ({'created' if config_created else 'kept'})")
84
+ print(f"[wte] project template: {template} ({'created' if template_created else 'kept'})")
85
+ print(f"[wte] hooks: {dispatcher.parent}")
86
+ return 0
87
+
88
+
89
+ def _cmd_sync(paths: AppPaths, setup: bool = False) -> int:
90
+ result = apply_worktree(paths, worktree_root(), setup=setup)
91
+ if result is None:
92
+ raise WteError("the current worktree does not match any project profile")
93
+ print_apply_result(result)
94
+ return 0
95
+
96
+
97
+ def _cmd_list(paths: AppPaths) -> int:
98
+ registry = prune_registry(load_registry(paths))
99
+ print(json.dumps(registry, indent=2, sort_keys=True) if registry else "(empty)")
100
+ return 0
101
+
102
+
103
+ def _print_validation(paths: AppPaths) -> int:
104
+ load_port_pool(paths)
105
+ errors, warnings = validate_profiles(paths)
106
+ for warning in warnings:
107
+ print(f"[wte] warning: {warning}")
108
+ for error in errors:
109
+ print(f"[wte] error: {error}", file=sys.stderr)
110
+ if errors:
111
+ return 1
112
+ print(f"[wte] configuration is valid ({len(load_profiles(paths))} profile(s))")
113
+ return 0
114
+
115
+
116
+ def _cmd_doctor(paths: AppPaths) -> int:
117
+ failures = 0
118
+ print(f"[wte] config: {paths.root}")
119
+ try:
120
+ print(f"[wte] git: {run_git('--version')}")
121
+ except WteError as exc:
122
+ print(f"[wte] error: {exc}", file=sys.stderr)
123
+ failures += 1
124
+
125
+ failures += int(_print_validation(paths) != 0)
126
+ try:
127
+ registry = load_registry(paths)
128
+ print(f"[wte] registry: {paths.registry} ({len(registry)} allocation(s))")
129
+ except WteError as exc:
130
+ print(f"[wte] error: {exc}", file=sys.stderr)
131
+ failures += 1
132
+
133
+ status = hooks_status(paths)
134
+ if status["installed"]:
135
+ print(f"[wte] hooks: installed at {status['expected_hooks_path']}")
136
+ print(f"[wte] hook executable: {status.get('wte_executable')}")
137
+ else:
138
+ print(f"[wte] warning: hooks are not installed (current: {status['current_hooks_path']})")
139
+
140
+ monitor = monitor_status(paths)
141
+ if monitor.installed and monitor.active:
142
+ print(f"[wte] reconciler: active via {monitor.detail} ({len(monitor.watch_paths)} path(s))")
143
+ else:
144
+ print(f"[wte] warning: reconciler is not active ({monitor.detail})")
145
+
146
+ for profile in load_profiles(paths, strict=False):
147
+ for entry in profile.get("secrets") or []:
148
+ if not isinstance(entry, dict) or not entry.get("source"):
149
+ continue
150
+ source = expand_profile_path(str(entry["source"]), profile).resolve()
151
+ if not source.is_file():
152
+ print(f"[wte] warning: missing secret source: {source}")
153
+ return 1 if failures else 0
154
+
155
+
156
+ def _cmd_monitor(paths: AppPaths, command: str) -> int:
157
+ if command == "enable":
158
+ monitor = install_monitor(paths)
159
+ if not monitor.installed:
160
+ raise WteError(monitor.detail)
161
+ print(f"[wte] reconciler: {monitor.detail} watching {len(monitor.watch_paths)} path(s)")
162
+ return 0
163
+ uninstall_monitor(paths)
164
+ print("[wte] reconciler monitoring disabled")
165
+ return 0
166
+
167
+
168
+ def _cmd_uninstall(paths: AppPaths) -> int:
169
+ uninstall_monitor(paths)
170
+ previous = uninstall_hooks(paths)
171
+ print("[wte] hooks and reconciler uninstalled; configuration and state were preserved")
172
+ print(f"[wte] restored core.hooksPath: {previous or '(unset)'}")
173
+ return 0
174
+
175
+
176
+ def _cmd_internal_hook(paths: AppPaths) -> int:
177
+ """Synchronize after checkout without ever blocking the Git operation."""
178
+ try:
179
+ result = apply_worktree(paths, worktree_root(), setup=True)
180
+ if result is not None:
181
+ print_apply_result(result)
182
+ return 0
183
+ except Exception as exc: # Git hooks must fail open, including unexpected errors.
184
+ log(f"hook skipped: {exc}")
185
+ return 0
186
+
187
+
188
+ def _cmd_internal_reconcile(paths: AppPaths) -> int:
189
+ """Reconcile host-visible worktrees after a common-dir filesystem event."""
190
+ try:
191
+ result = reconcile_with_retry(paths)
192
+ print(
193
+ f"[wte] reconcile: discovered={result.discovered} "
194
+ f"applied={result.applied} pending={result.pending}"
195
+ )
196
+ return 0 if result.pending == 0 else 1
197
+ except Exception as exc:
198
+ log(f"reconcile failed: {exc}")
199
+ return 1
200
+
201
+
202
+ def main(argv: Optional[Sequence[str]] = None) -> int:
203
+ """Parse arguments and return a process exit status."""
204
+ arguments = list(argv) if argv is not None else sys.argv[1:]
205
+ paths = AppPaths.discover()
206
+ if arguments and arguments[0] == INTERNAL_HOOK_COMMAND:
207
+ return _cmd_internal_hook(paths)
208
+ if arguments and arguments[0] == INTERNAL_RECONCILE_COMMAND:
209
+ return _cmd_internal_reconcile(paths)
210
+
211
+ _warn_if_legacy_distribution_installed()
212
+ args = _build_parser().parse_args(arguments)
213
+ try:
214
+ if args.command == "init":
215
+ return _cmd_init(paths)
216
+ if args.command == "sync":
217
+ return _cmd_sync(paths)
218
+ if args.command == "list":
219
+ return _cmd_list(paths)
220
+ if args.command == "doctor":
221
+ return _cmd_doctor(paths)
222
+ if args.command == "monitor":
223
+ return _cmd_monitor(paths, args.monitor_command)
224
+ if args.command == "uninstall":
225
+ return _cmd_uninstall(paths)
226
+ raise WteError(f"unknown command: {args.command}")
227
+ except WteError as exc:
228
+ log(str(exc))
229
+ return 1
230
+
231
+
232
+ if __name__ == "__main__":
233
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ """Machine-wide port-pool configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Dict
8
+
9
+ import yaml
10
+
11
+ from .paths import AppPaths
12
+ from .utils import WteError
13
+
14
+ DEFAULT_POOL_START = 20000
15
+ DEFAULT_POOL_END = 29999
16
+ DEFAULT_PROFILE_TEMPLATE_NAME = "project.example.yaml.template"
17
+ DEFAULT_CONFIG = """# Inclusive range used for per-worktree port allocation.\nport_range:\n start: 20000\n end: 29999\n"""
18
+ DEFAULT_PROFILE_TEMPLATE = """# Copy this file to a root-level *.yaml file, then edit every example value.
19
+ # Example: cp project.example.yaml.template my-project.yaml
20
+ # Run `wte monitor enable` after adding or changing profiles to refresh monitoring.
21
+
22
+ # A unique identifier stored in the local port registry.
23
+ name: example-fullstack
24
+
25
+ match:
26
+ # Exact path of this project's dedicated main worktree. Linked worktrees
27
+ # created from it are matched automatically, regardless of their location.
28
+ main_worktree: $HOME/code/example-app
29
+
30
+ # Port IDs form one contiguous block in declaration order. Use each ID as a
31
+ # ${placeholder} in writes below.
32
+ ports:
33
+ - id: frontend
34
+ - id: backend
35
+
36
+ # Optional local secret files. Sources stay outside Git; targets are replaced
37
+ # with symlinks inside each matched worktree.
38
+ secrets:
39
+ - source: $HOME/.config/example-app/backend.env
40
+ target: apps/backend/.env
41
+
42
+ # Optional generated files. Each target is overwritten completely on sync.
43
+ writes:
44
+ - path: apps/frontend/.env.development
45
+ body: |
46
+ VITE_PORT=${frontend}
47
+ VITE_API_URL=http://127.0.0.1:${backend}
48
+
49
+ - path: apps/backend/.env.development
50
+ body: |
51
+ PORT=${backend}
52
+ CORS_ORIGIN=http://127.0.0.1:${frontend}
53
+
54
+ # Optional post-checkout initializers. Commands are trusted local configuration
55
+ # executed by Bash in the background. skip_if is resolved relative to cwd.
56
+ init:
57
+ - command: npm install
58
+ cwd: .
59
+ skip_if: node_modules
60
+ """
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class PortPool:
65
+ """Inclusive bounds of the machine-local port pool."""
66
+
67
+ start: int
68
+ end: int
69
+
70
+ def validate(self) -> None:
71
+ if not 1 <= self.start <= 65535:
72
+ raise WteError(f"port_range.start is outside 1-65535: {self.start}")
73
+ if not 1 <= self.end <= 65535:
74
+ raise WteError(f"port_range.end is outside 1-65535: {self.end}")
75
+ if self.end < self.start:
76
+ raise WteError("port_range.end must be greater than or equal to port_range.start")
77
+
78
+
79
+ def _load_yaml_mapping(path: Path) -> Dict[str, Any]:
80
+ try:
81
+ raw = yaml.safe_load(path.read_text()) or {}
82
+ except (OSError, yaml.YAMLError) as exc:
83
+ raise WteError(f"cannot read YAML file {path}: {exc}") from exc
84
+ if not isinstance(raw, dict):
85
+ raise WteError(f"YAML root must be a mapping: {path}")
86
+ return raw
87
+
88
+
89
+ def load_port_pool(paths: AppPaths) -> PortPool:
90
+ """Load the configured pool, using defaults when config.yaml is absent."""
91
+ if not paths.config.exists():
92
+ pool = PortPool(DEFAULT_POOL_START, DEFAULT_POOL_END)
93
+ pool.validate()
94
+ return pool
95
+ data = _load_yaml_mapping(paths.config)
96
+ if "pool" in data and "port_range" not in data:
97
+ raise WteError("config key 'pool' was renamed to 'port_range'")
98
+ raw_pool = data.get("port_range") or {}
99
+ if not isinstance(raw_pool, dict):
100
+ raise WteError(f"port_range must be a mapping: {paths.config}")
101
+ try:
102
+ start = int(raw_pool.get("start") or DEFAULT_POOL_START)
103
+ end = int(raw_pool.get("end") or DEFAULT_POOL_END)
104
+ except (TypeError, ValueError) as exc:
105
+ raise WteError("port_range.start and port_range.end must be integers") from exc
106
+ pool = PortPool(start, end)
107
+ pool.validate()
108
+ return pool
109
+
110
+
111
+ def initialize_config(paths: AppPaths) -> bool:
112
+ """Create the default config if absent; return whether it was created."""
113
+ paths.ensure()
114
+ if paths.config.exists():
115
+ return False
116
+ paths.config.write_text(DEFAULT_CONFIG)
117
+ return True
118
+
119
+
120
+ def initialize_profile_template(paths: AppPaths) -> tuple[Path, bool]:
121
+ """Create the commented project template without making it an active profile."""
122
+ paths.ensure()
123
+ template = paths.root / DEFAULT_PROFILE_TEMPLATE_NAME
124
+ if template.exists():
125
+ return template, False
126
+ template.write_text(DEFAULT_PROFILE_TEMPLATE)
127
+ return template, True
@@ -0,0 +1,217 @@
1
+ """Installation and removal of the global Git hook dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ from .paths import AppPaths
16
+ from .utils import WteError
17
+
18
+ # A dispatcher is installed for each common client-side hook. Most only chain
19
+ # an existing hook; post-checkout additionally invokes wte.
20
+ HOOK_NAMES = (
21
+ "applypatch-msg",
22
+ "pre-applypatch",
23
+ "post-applypatch",
24
+ "pre-commit",
25
+ "pre-merge-commit",
26
+ "prepare-commit-msg",
27
+ "commit-msg",
28
+ "post-commit",
29
+ "pre-rebase",
30
+ "post-checkout",
31
+ "post-merge",
32
+ "pre-push",
33
+ "pre-auto-gc",
34
+ "post-rewrite",
35
+ "post-index-change",
36
+ "sendemail-validate",
37
+ "reference-transaction",
38
+ )
39
+
40
+
41
+ def _git_config(*args: str, check: bool = True) -> subprocess.CompletedProcess:
42
+ result = subprocess.run(
43
+ ["git", "config", "--global", *args],
44
+ stdout=subprocess.PIPE,
45
+ stderr=subprocess.PIPE,
46
+ universal_newlines=True,
47
+ )
48
+ if check and result.returncode != 0:
49
+ raise WteError(result.stderr.strip() or "git config failed")
50
+ return result
51
+
52
+
53
+ def current_hooks_path() -> Optional[str]:
54
+ """Return the raw global core.hooksPath, if configured."""
55
+ result = _git_config("--get", "core.hooksPath", check=False)
56
+ if result.returncode != 0:
57
+ return None
58
+ return result.stdout.strip() or None
59
+
60
+
61
+ def _write_state(path: Path, state: Dict[str, Any]) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ descriptor, temporary = tempfile.mkstemp(prefix=".hooks-state-", dir=path.parent)
64
+ temp_path = Path(temporary)
65
+ try:
66
+ with os.fdopen(descriptor, "w") as handle:
67
+ json.dump(state, handle, indent=2, sort_keys=True)
68
+ handle.write("\n")
69
+ handle.flush()
70
+ os.fsync(handle.fileno())
71
+ os.chmod(temp_path, 0o600)
72
+ os.replace(temp_path, path)
73
+ finally:
74
+ if temp_path.exists():
75
+ temp_path.unlink()
76
+
77
+
78
+ def _read_state(paths: AppPaths) -> Dict[str, Any]:
79
+ if not paths.hooks_state.exists():
80
+ return {}
81
+ try:
82
+ raw = json.loads(paths.hooks_state.read_text() or "{}")
83
+ except (OSError, json.JSONDecodeError) as exc:
84
+ raise WteError(f"cannot read hook state {paths.hooks_state}: {exc}") from exc
85
+ if not isinstance(raw, dict):
86
+ raise WteError(f"hook state must be an object: {paths.hooks_state}")
87
+ return raw
88
+
89
+
90
+ def resolve_wte_executable() -> Path:
91
+ """Resolve an absolute executable path suitable for GUI-launched Git hooks."""
92
+ candidate = shutil.which("wte")
93
+ if candidate:
94
+ return Path(candidate).resolve()
95
+ argv0 = Path(sys.argv[0])
96
+ if argv0.exists():
97
+ return argv0.resolve()
98
+ raise WteError("cannot locate the wte executable; install it before installing hooks")
99
+
100
+
101
+ def _dispatcher_script(executable: Path, previous: Optional[str]) -> str:
102
+ executable_value = shlex.quote(str(executable))
103
+ previous_value = shlex.quote(previous or "")
104
+ return f'''#!/usr/bin/env bash
105
+ # Generated by wte. Re-run `wte init` instead of editing this file.
106
+ set -u
107
+
108
+ HOOK_NAME="$(basename "$0")"
109
+ WTE_EXECUTABLE={executable_value}
110
+ PREVIOUS_HOOKS_PATH={previous_value}
111
+
112
+ if [[ "$HOOK_NAME" == "post-checkout" ]]; then
113
+ "$WTE_EXECUTABLE" _hook "$@" || true
114
+ fi
115
+
116
+ # Preserve a hooksPath that existed before wte was installed.
117
+ if [[ -n "$PREVIOUS_HOOKS_PATH" ]]; then
118
+ if [[ "$PREVIOUS_HOOKS_PATH" = /* ]]; then
119
+ CHAIN_TARGET="$PREVIOUS_HOOKS_PATH/$HOOK_NAME"
120
+ else
121
+ CHAIN_TARGET="$PWD/$PREVIOUS_HOOKS_PATH/$HOOK_NAME"
122
+ fi
123
+ if [[ -x "$CHAIN_TARGET" ]]; then
124
+ exec "$CHAIN_TARGET" "$@"
125
+ fi
126
+ fi
127
+
128
+ # Fall back to the repository's native hook directory.
129
+ COMMON="$(git rev-parse --git-common-dir 2>/dev/null)" || exit 0
130
+ LOCAL="$COMMON/hooks/$HOOK_NAME"
131
+ if [[ -x "$LOCAL" ]]; then
132
+ DISPATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
133
+ LOCAL_DIR="$(cd "$(dirname "$LOCAL")" && pwd)"
134
+ if [[ "$LOCAL_DIR/$HOOK_NAME" != "$DISPATCH_DIR/$HOOK_NAME" && "$LOCAL_DIR/$HOOK_NAME" != "$DISPATCH_DIR/_dispatch" ]]; then
135
+ exec "$LOCAL" "$@"
136
+ fi
137
+ fi
138
+ exit 0
139
+ '''
140
+
141
+
142
+ def install_hooks(paths: AppPaths) -> Path:
143
+ """Install the global dispatcher without silently replacing another manager."""
144
+ paths.ensure()
145
+ target = str(paths.hooks)
146
+ current = current_hooks_path()
147
+ legacy = str(Path.home() / ".config" / "git-projects" / "hooks")
148
+
149
+ previous: Optional[str] = None
150
+ if current and Path(os.path.expanduser(current)).resolve() != paths.hooks.resolve():
151
+ if Path(os.path.expanduser(current)).resolve() == Path(legacy).resolve():
152
+ # This is the pre-open-source version of wte, not an independent manager.
153
+ previous = None
154
+ else:
155
+ raise WteError(
156
+ f"core.hooksPath is already set to {current!r}; wte did not change it"
157
+ )
158
+ elif paths.hooks_state.exists():
159
+ previous_raw = _read_state(paths).get("previous_hooks_path")
160
+ previous = str(previous_raw) if previous_raw else None
161
+
162
+ executable = resolve_wte_executable()
163
+ paths.hooks.mkdir(parents=True, exist_ok=True)
164
+ dispatcher = paths.hooks / "_dispatch"
165
+ dispatcher.write_text(_dispatcher_script(executable, previous))
166
+ dispatcher.chmod(0o755)
167
+
168
+ for name in HOOK_NAMES:
169
+ hook = paths.hooks / name
170
+ if hook.is_symlink() or hook.exists():
171
+ if hook.is_dir() and not hook.is_symlink():
172
+ raise WteError(f"cannot replace hook directory: {hook}")
173
+ hook.unlink()
174
+ hook.symlink_to("_dispatch")
175
+
176
+ _write_state(
177
+ paths.hooks_state,
178
+ {
179
+ "previous_hooks_path": previous,
180
+ "installed_hooks_path": target,
181
+ "wte_executable": str(executable),
182
+ },
183
+ )
184
+ _git_config("core.hooksPath", target)
185
+ return dispatcher
186
+
187
+
188
+ def uninstall_hooks(paths: AppPaths) -> Optional[str]:
189
+ """Remove wte's dispatcher and restore the previous global hooksPath."""
190
+ state = _read_state(paths)
191
+ previous_raw = state.get("previous_hooks_path")
192
+ previous = str(previous_raw) if previous_raw else None
193
+ current = current_hooks_path()
194
+ if current and Path(os.path.expanduser(current)).resolve() == paths.hooks.resolve():
195
+ if previous:
196
+ _git_config("core.hooksPath", previous)
197
+ else:
198
+ _git_config("--unset", "core.hooksPath", check=False)
199
+ if paths.hooks.exists():
200
+ shutil.rmtree(paths.hooks)
201
+ if paths.hooks_state.exists():
202
+ paths.hooks_state.unlink()
203
+ return previous
204
+
205
+
206
+ def hooks_status(paths: AppPaths) -> Dict[str, Any]:
207
+ """Return current installation details for human-readable reporting."""
208
+ state = _read_state(paths)
209
+ current = current_hooks_path()
210
+ installed = bool(current) and Path(os.path.expanduser(current)).resolve() == paths.hooks.resolve()
211
+ return {
212
+ "installed": installed,
213
+ "current_hooks_path": current,
214
+ "expected_hooks_path": str(paths.hooks),
215
+ "previous_hooks_path": state.get("previous_hooks_path"),
216
+ "wte_executable": state.get("wte_executable"),
217
+ }
@@ -0,0 +1,64 @@
1
+ """Filesystem locations used by wte.
2
+
3
+ Application code and user data are intentionally separate. The package can be
4
+ installed anywhere, while mutable configuration stays under the user's XDG
5
+ configuration directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AppPaths:
17
+ """Resolved paths for one wte configuration directory."""
18
+
19
+ root: Path
20
+ config: Path
21
+ profiles: Path
22
+ state: Path
23
+ registry: Path
24
+ lock: Path
25
+ hooks: Path
26
+ hooks_state: Path
27
+
28
+ @classmethod
29
+ def discover(cls) -> "AppPaths":
30
+ """Resolve paths from the environment.
31
+
32
+ ``WTE_CONFIG_HOME`` is primarily useful for tests and portable setups.
33
+ Otherwise wte follows ``XDG_CONFIG_HOME`` and falls back to
34
+ ``~/.config/wte``.
35
+ """
36
+ override = os.environ.get("WTE_CONFIG_HOME")
37
+ if override:
38
+ root = Path(os.path.expanduser(override)).resolve()
39
+ else:
40
+ xdg = os.environ.get("XDG_CONFIG_HOME")
41
+ base = Path(os.path.expanduser(xdg)) if xdg else Path.home() / ".config"
42
+ root = (base / "wte").resolve()
43
+ state = root / "state"
44
+ return cls(
45
+ root=root,
46
+ config=root / "config.yaml",
47
+ profiles=root,
48
+ state=state,
49
+ registry=state / "ports.json",
50
+ lock=state / "ports.lock",
51
+ hooks=root / "hooks",
52
+ hooks_state=state / "hooks-state.json",
53
+ )
54
+
55
+ def ensure(self) -> None:
56
+ """Create private configuration and state directories."""
57
+ self.root.mkdir(parents=True, exist_ok=True)
58
+ self.state.mkdir(parents=True, exist_ok=True)
59
+ for directory in (self.root, self.state):
60
+ try:
61
+ directory.chmod(0o700)
62
+ except OSError:
63
+ # Some network filesystems do not expose POSIX permission bits.
64
+ pass