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
git_worktree_env/cli.py
ADDED
|
@@ -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
|