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,166 @@
|
|
|
1
|
+
"""Concurrent, sticky allocation of per-worktree port blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import tempfile
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Iterator, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from .config import PortPool
|
|
15
|
+
from .paths import AppPaths
|
|
16
|
+
from .profiles import Profile
|
|
17
|
+
from .utils import WteError
|
|
18
|
+
|
|
19
|
+
Registry = Dict[str, Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_registry(paths: AppPaths) -> Registry:
|
|
23
|
+
"""Load the registry and reject corruption instead of silently resetting it."""
|
|
24
|
+
if not paths.registry.exists():
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
raw = json.loads(paths.registry.read_text() or "{}")
|
|
28
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
29
|
+
raise WteError(f"cannot read port registry {paths.registry}: {exc}") from exc
|
|
30
|
+
if not isinstance(raw, dict):
|
|
31
|
+
raise WteError(f"port registry root must be an object: {paths.registry}")
|
|
32
|
+
return raw
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_registry(paths: AppPaths, registry: Registry) -> None:
|
|
36
|
+
"""Atomically replace the registry while preserving a valid old copy on crash."""
|
|
37
|
+
paths.root.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
payload = json.dumps(registry, indent=2, sort_keys=True) + "\n"
|
|
39
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".ports-", suffix=".tmp", dir=paths.root)
|
|
40
|
+
temp_path = Path(temporary)
|
|
41
|
+
try:
|
|
42
|
+
with os.fdopen(descriptor, "w") as handle:
|
|
43
|
+
handle.write(payload)
|
|
44
|
+
handle.flush()
|
|
45
|
+
os.fsync(handle.fileno())
|
|
46
|
+
os.chmod(temp_path, 0o600)
|
|
47
|
+
os.replace(temp_path, paths.registry)
|
|
48
|
+
try:
|
|
49
|
+
directory_fd = os.open(str(paths.root), os.O_RDONLY)
|
|
50
|
+
try:
|
|
51
|
+
os.fsync(directory_fd)
|
|
52
|
+
finally:
|
|
53
|
+
os.close(directory_fd)
|
|
54
|
+
except OSError:
|
|
55
|
+
# Directory fsync is not available on every supported filesystem.
|
|
56
|
+
pass
|
|
57
|
+
finally:
|
|
58
|
+
if temp_path.exists():
|
|
59
|
+
temp_path.unlink()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def registry_lock(paths: AppPaths) -> Iterator[None]:
|
|
64
|
+
"""Serialize registry reads, allocation, garbage collection, and writes."""
|
|
65
|
+
paths.root.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
paths.lock.touch(exist_ok=True)
|
|
67
|
+
try:
|
|
68
|
+
os.chmod(paths.lock, 0o600)
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
with paths.lock.open("a+") as handle:
|
|
72
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_live_worktree(path: str) -> bool:
|
|
80
|
+
"""Return whether a registered worktree directory still exists."""
|
|
81
|
+
return Path(path).is_dir()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def prune_registry(registry: Registry) -> Registry:
|
|
85
|
+
"""Drop entries whose worktree directory no longer exists."""
|
|
86
|
+
return {
|
|
87
|
+
path: metadata
|
|
88
|
+
for path, metadata in registry.items()
|
|
89
|
+
if isinstance(metadata, dict) and is_live_worktree(path)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def port_is_free(port: int) -> bool:
|
|
94
|
+
"""Return whether a new process can bind the port on loopback."""
|
|
95
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
96
|
+
try:
|
|
97
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
98
|
+
sock.bind(("127.0.0.1", port))
|
|
99
|
+
return True
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
finally:
|
|
103
|
+
sock.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _used_by_other_worktrees(registry: Registry, current: str) -> Set[int]:
|
|
107
|
+
used: Set[int] = set()
|
|
108
|
+
for path, metadata in registry.items():
|
|
109
|
+
if path == current or not isinstance(metadata, dict) or not Path(path).exists():
|
|
110
|
+
continue
|
|
111
|
+
ports = metadata.get("ports") or {}
|
|
112
|
+
if not isinstance(ports, dict):
|
|
113
|
+
continue
|
|
114
|
+
for raw_port in ports.values():
|
|
115
|
+
try:
|
|
116
|
+
used.add(int(raw_port))
|
|
117
|
+
except (TypeError, ValueError):
|
|
118
|
+
continue
|
|
119
|
+
return used
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def allocate_ports(
|
|
123
|
+
profile: Profile,
|
|
124
|
+
root: Path,
|
|
125
|
+
registry: Registry,
|
|
126
|
+
pool: PortPool,
|
|
127
|
+
) -> Tuple[int, Dict[str, int]]:
|
|
128
|
+
"""Reuse a valid sticky allocation or claim the first free contiguous block."""
|
|
129
|
+
claims = profile.get("ports") or profile.get("services") or []
|
|
130
|
+
if not isinstance(claims, list) or not claims:
|
|
131
|
+
raise WteError(f"profile {profile.get('name')} has no ports to claim")
|
|
132
|
+
try:
|
|
133
|
+
ids = [str(item["id"]) for item in claims]
|
|
134
|
+
except (TypeError, KeyError) as exc:
|
|
135
|
+
raise WteError(f"profile {profile.get('name')} has an invalid port claim") from exc
|
|
136
|
+
|
|
137
|
+
count = len(ids)
|
|
138
|
+
current = str(root)
|
|
139
|
+
used = _used_by_other_worktrees(registry, current)
|
|
140
|
+
existing = registry.get(current) if isinstance(registry.get(current), dict) else None
|
|
141
|
+
profile_file = Path(profile.get("_file") or "").name
|
|
142
|
+
if existing:
|
|
143
|
+
same_profile = existing.get("profile") == profile.get("name")
|
|
144
|
+
# File identity preserves allocations created before profiles had unique names.
|
|
145
|
+
same_file = bool(profile_file) and existing.get("file") == profile_file
|
|
146
|
+
saved = existing.get("ports") or {}
|
|
147
|
+
if (same_profile or same_file) and isinstance(saved, dict):
|
|
148
|
+
try:
|
|
149
|
+
values = [int(saved[port_id]) for port_id in ids]
|
|
150
|
+
except (KeyError, TypeError, ValueError):
|
|
151
|
+
values = []
|
|
152
|
+
if (
|
|
153
|
+
len(values) == count
|
|
154
|
+
and all(pool.start <= port <= pool.end for port in values)
|
|
155
|
+
and not (set(values) & used)
|
|
156
|
+
):
|
|
157
|
+
return min(values), dict(zip(ids, values))
|
|
158
|
+
|
|
159
|
+
last_start = pool.end - count + 1
|
|
160
|
+
for start in range(pool.start, last_start + 1):
|
|
161
|
+
ports = list(range(start, start + count))
|
|
162
|
+
if set(ports) & used:
|
|
163
|
+
continue
|
|
164
|
+
if all(port_is_free(port) for port in ports):
|
|
165
|
+
return start, dict(zip(ids, ports))
|
|
166
|
+
raise WteError(f"no free block of {count} ports in pool {pool.start}-{pool.end}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Small shared helpers for subprocesses, paths, and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from string import Template
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WteError(RuntimeError):
|
|
14
|
+
"""Base exception for expected user-facing failures."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log(message: str) -> None:
|
|
18
|
+
"""Write a consistently prefixed diagnostic to stderr."""
|
|
19
|
+
sys.stderr.write(f"[wte] {message}\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_git(*args: str, cwd: Optional[Path] = None) -> str:
|
|
23
|
+
"""Run Git and return stripped stdout, raising a user-facing error."""
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["git", *args],
|
|
26
|
+
cwd=str(cwd) if cwd else None,
|
|
27
|
+
stdout=subprocess.PIPE,
|
|
28
|
+
stderr=subprocess.PIPE,
|
|
29
|
+
universal_newlines=True,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
detail = result.stderr.strip() or f"git {' '.join(args)} failed"
|
|
33
|
+
raise WteError(detail)
|
|
34
|
+
return result.stdout.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def expand_home(value: str) -> Path:
|
|
38
|
+
"""Expand ``~``, ``$HOME``, and ``${HOME}`` in a configured path."""
|
|
39
|
+
text = str(value).strip()
|
|
40
|
+
home = str(Path.home())
|
|
41
|
+
text = text.replace("${HOME}", home).replace("$HOME", home)
|
|
42
|
+
return Path(os.path.expanduser(text))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def expand_profile_path(
|
|
46
|
+
value: str,
|
|
47
|
+
profile: Dict[str, Any],
|
|
48
|
+
root: Optional[Path] = None,
|
|
49
|
+
) -> Path:
|
|
50
|
+
"""Expand profile variables and resolve a relative path against ``root``."""
|
|
51
|
+
text = str(value).strip()
|
|
52
|
+
home = str(Path.home())
|
|
53
|
+
text = text.replace("${HOME}", home).replace("$HOME", home)
|
|
54
|
+
text = Template(text).safe_substitute(
|
|
55
|
+
name=profile.get("name") or "",
|
|
56
|
+
HOME=home,
|
|
57
|
+
)
|
|
58
|
+
path = Path(os.path.expanduser(text))
|
|
59
|
+
if not path.is_absolute() and root is not None:
|
|
60
|
+
path = root / path
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_within(path: Path, root: Path) -> bool:
|
|
65
|
+
"""Return whether a resolved path is inside a resolved root (Python 3.9)."""
|
|
66
|
+
try:
|
|
67
|
+
path.resolve().relative_to(root.resolve())
|
|
68
|
+
return True
|
|
69
|
+
except ValueError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def safe_worktree_target(root: Path, value: str) -> Path:
|
|
74
|
+
"""Resolve a profile target and reject paths outside the worktree."""
|
|
75
|
+
raw = Path(str(value))
|
|
76
|
+
target = raw if raw.is_absolute() else root / raw
|
|
77
|
+
# Resolve the parent but not the final component: an existing target may be
|
|
78
|
+
# a secret symlink that intentionally points outside the worktree.
|
|
79
|
+
target = target.parent.resolve(strict=False) / target.name
|
|
80
|
+
if not is_within(target.parent, root):
|
|
81
|
+
raise WteError(f"target escapes the worktree: {value}")
|
|
82
|
+
return target
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: worktree-env
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Per-worktree ports, secrets, and local environment setup for Git
|
|
5
|
+
Project-URL: Homepage, https://github.com/archcst/worktree-env
|
|
6
|
+
Project-URL: Repository, https://github.com/archcst/worktree-env
|
|
7
|
+
Project-URL: Issues, https://github.com/archcst/worktree-env/issues
|
|
8
|
+
Author: Shitong Chen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: development,environment,git,ports,worktree
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# worktree-env (`wte`)
|
|
29
|
+
|
|
30
|
+
[中文文档](README.zh-CN.md)
|
|
31
|
+
|
|
32
|
+
Automatically prepare an isolated, runnable local development environment for every
|
|
33
|
+
Git worktree. Lightweight, minimal, no magic.
|
|
34
|
+
|
|
35
|
+
## The problem
|
|
36
|
+
|
|
37
|
+
Git worktrees are widely used for parallel development and task isolation. However,
|
|
38
|
+
a new worktree usually contains only code, not a development environment that is
|
|
39
|
+
ready to run:
|
|
40
|
+
|
|
41
|
+
- The frontend, backend, database, and debugger still use the same fixed ports,
|
|
42
|
+
preventing multiple worktrees from running at the same time.
|
|
43
|
+
- `.env` files, private keys, and other local secrets must be copied repeatedly and
|
|
44
|
+
can easily be committed by mistake.
|
|
45
|
+
- URLs and port settings shared by services within a project must be kept in sync
|
|
46
|
+
manually.
|
|
47
|
+
|
|
48
|
+
Common solutions often require adding extra scripts to the project, changing how it
|
|
49
|
+
is started, modifying `AGENTS.md` or `CLAUDE.md`, or creating skills.
|
|
50
|
+
These approaches are intrusive to some degree: they must either be adopted across
|
|
51
|
+
the team or affect how other team members work.
|
|
52
|
+
|
|
53
|
+
`wte` uses a Git `post-checkout` hook to assign stable, conflict-free ports to a
|
|
54
|
+
project's worktrees, link environment variables, and generate local configuration.
|
|
55
|
+
The hook is not committed to the repository. It is available globally on the local
|
|
56
|
+
machine and does not modify any project code.
|
|
57
|
+
|
|
58
|
+
## Comparison with existing tools
|
|
59
|
+
|
|
60
|
+
- [Portless](https://github.com/vercel-labs/portless): Requires applications to be
|
|
61
|
+
started through `portless`, introducing a reverse proxy, a local CA, and a
|
|
62
|
+
background service.
|
|
63
|
+
- [devports](https://github.com/bendechrai/devports): Wraps worktree creation and
|
|
64
|
+
removal in `devports` commands; worktrees created directly by an agent or IDE are
|
|
65
|
+
not handled automatically.
|
|
66
|
+
- [Worktrunk](https://worktrunk.dev/): Replaces the native Git workflow with `wt`,
|
|
67
|
+
does not participate in environment setup, and does not automatically handle
|
|
68
|
+
worktrees created directly by an agent or IDE.
|
|
69
|
+
- [workz](https://github.com/rohansx/workz): Uses `.workz.toml`,
|
|
70
|
+
`workz sync`/`workz start`, or separately configured hooks for Cursor, Claude Code,
|
|
71
|
+
and Worktrunk.
|
|
72
|
+
- [Hyve](https://github.com/eladkishon/hyve): Adopts a
|
|
73
|
+
`hyve create`/`hyve run` workflow and depends on Docker, database containers, and
|
|
74
|
+
service orchestration.
|
|
75
|
+
|
|
76
|
+
`wte` does not take over how worktrees are created or how a project is started.
|
|
77
|
+
Instead, it automatically projects a complete local development environment after a
|
|
78
|
+
worktree is created. It requires no changes to project code, start commands, or agent
|
|
79
|
+
prompts; no scripts need to be added to the project, and no traffic proxy or resident
|
|
80
|
+
process is required. Worktrees created by Git, an IDE, or a coding agent can all be
|
|
81
|
+
handled automatically.
|
|
82
|
+
|
|
83
|
+
All rules are declared explicitly in profiles stored outside the repository. For each
|
|
84
|
+
worktree, `wte` assigns stable ports, mounts secrets, generates local configuration,
|
|
85
|
+
and can initialize dependencies in the background, making the worktree ready
|
|
86
|
+
immediately after creation.
|
|
87
|
+
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
- Allocates contiguous port blocks from a machine-wide shared pool and keeps them
|
|
91
|
+
stable for the lifetime of the worktree path.
|
|
92
|
+
- Mounts secrets stored outside the repository into the worktree as symlinks,
|
|
93
|
+
preserving a single source of truth and avoiding copy and paste.
|
|
94
|
+
- Organizes configuration by repository rather than by service. Supports monorepos
|
|
95
|
+
and multiple port requests.
|
|
96
|
+
- Optionally monitors host directories to discover newly added linked worktrees,
|
|
97
|
+
including those created within coding agent sandboxes.
|
|
98
|
+
- Preserves the project's own Git hooks. After the `post-checkout` hook for `wte`
|
|
99
|
+
finishes, it invokes the project's own executable Git hook.
|
|
100
|
+
- Zero intrusion: no wrapper, no skill, and no changes to coding agent prompts or
|
|
101
|
+
project start commands.
|
|
102
|
+
|
|
103
|
+
## Requirements
|
|
104
|
+
|
|
105
|
+
- macOS or Linux
|
|
106
|
+
- Python 3.9+
|
|
107
|
+
- Git
|
|
108
|
+
- Bash
|
|
109
|
+
|
|
110
|
+
## Installation
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv tool install worktree-env
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Upgrading
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv tool upgrade worktree-env
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Migrating from `git-worktree-env`
|
|
123
|
+
|
|
124
|
+
The PyPI package and GitHub repository were renamed in version 0.2.0. Existing
|
|
125
|
+
configuration under `~/.config/wte/` is fully compatible:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv tool uninstall git-worktree-env
|
|
129
|
+
uv tool install worktree-env
|
|
130
|
+
wte init
|
|
131
|
+
# If you previously enabled the optional Monitor:
|
|
132
|
+
wte monitor enable
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Running `wte init` refreshes the Git hook's absolute executable path. The `wte`
|
|
136
|
+
command and all existing profiles and state remain unchanged.
|
|
137
|
+
|
|
138
|
+
## Getting started
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
wte init
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This command:
|
|
145
|
+
|
|
146
|
+
- Initializes the personal configuration directory at `~/.config/wte/`.
|
|
147
|
+
- Runs `git config --global core.hooksPath ~/.config/wte/hooks` to install the global
|
|
148
|
+
Git hook dispatcher.
|
|
149
|
+
|
|
150
|
+
> If the global `core.hooksPath` already points somewhere else, `wte` displays the
|
|
151
|
+
> current value and refuses to change it. Confirm its purpose and migrate or remove
|
|
152
|
+
> it as appropriate before retrying `wte init`.
|
|
153
|
+
|
|
154
|
+
## `~/.config/wte/`
|
|
155
|
+
|
|
156
|
+
Personal profiles, hooks, and runtime state are stored together in:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
~/.config/wte/
|
|
160
|
+
├── config.yaml # Machine-wide port pool
|
|
161
|
+
├── project_a.yaml # Project A profile
|
|
162
|
+
├── project_b.yaml # Project B profile
|
|
163
|
+
├── hooks/ # Global Git hook dispatcher
|
|
164
|
+
└── state/ # Managed by wte; do not edit manually.
|
|
165
|
+
├── ports.json # Port registry
|
|
166
|
+
├── ports.lock # Concurrency lock
|
|
167
|
+
├── hooks-state.json # Hook installation state
|
|
168
|
+
└── reconciler.log # Monitor log (present only when the optional Monitor is enabled; see the Monitor section)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Every root-level `*.yaml` file except `config.yaml` is loaded as a project profile.
|
|
172
|
+
|
|
173
|
+
Set `WTE_CONFIG_HOME` to change this directory. When it is not set,
|
|
174
|
+
`XDG_CONFIG_HOME` is respected.
|
|
175
|
+
|
|
176
|
+
## Configuration examples
|
|
177
|
+
|
|
178
|
+
### Port range
|
|
179
|
+
|
|
180
|
+
The machine-wide port range is configured in `~/.config/wte/config.yaml`:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
port_range:
|
|
184
|
+
start: 20000
|
|
185
|
+
end: 29999
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The default range is `20000-29999`. You can change it manually; new worktrees will
|
|
189
|
+
be allocated from the new range, while existing allocations remain unchanged.
|
|
190
|
+
|
|
191
|
+
### Project profile
|
|
192
|
+
|
|
193
|
+
Copy the configuration template:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
cp ~/.config/wte/project.example.yaml.template \
|
|
197
|
+
~/.config/wte/example-project.yaml
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The following profile describes a project with separate frontend and backend services:
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
name: example-project
|
|
204
|
+
|
|
205
|
+
match:
|
|
206
|
+
# Points to the project's main worktree directory.
|
|
207
|
+
main_worktree: $HOME/code/example-app
|
|
208
|
+
|
|
209
|
+
ports:
|
|
210
|
+
# Names of the ports to request. Add as many as the project needs;
|
|
211
|
+
# each id must be unique within the profile.
|
|
212
|
+
- id: frontend_port
|
|
213
|
+
- id: backend_port
|
|
214
|
+
|
|
215
|
+
secrets:
|
|
216
|
+
# Environment files shared through symlinks.
|
|
217
|
+
# source points to the original environment file, while target is a path
|
|
218
|
+
# relative to the worktree root.
|
|
219
|
+
- source: $HOME/path/to/your/frontend.env
|
|
220
|
+
target: frontend-dir/.env
|
|
221
|
+
- source: $HOME/path/to/your/backend.env
|
|
222
|
+
target: backend-dir/.env
|
|
223
|
+
|
|
224
|
+
writes:
|
|
225
|
+
# Frontend configuration:
|
|
226
|
+
- path: frontend-dir/.env.development
|
|
227
|
+
body: |
|
|
228
|
+
VITE_PORT=${frontend_port}
|
|
229
|
+
SERVER_URL=http://127.0.0.1:${backend_port}
|
|
230
|
+
|
|
231
|
+
# Backend configuration:
|
|
232
|
+
- path: backend-dir/.env.development
|
|
233
|
+
body: |
|
|
234
|
+
PORT=${backend_port}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
> This example applies when both the frontend and backend can load `.env.{env name}`
|
|
238
|
+
> files. Adjust it to match how your project loads environment variables.
|
|
239
|
+
>
|
|
240
|
+
> After a worktree directory is deleted, its ports are reclaimed the next time `wte`
|
|
241
|
+
> is triggered.
|
|
242
|
+
|
|
243
|
+
## Monitor
|
|
244
|
+
|
|
245
|
+
Some coding agents create worktrees inside a sandbox, which can prevent the `wte` hook from running.
|
|
246
|
+
To support these tools, enable the Monitor:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
wte monitor enable
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
It watches the `.git/worktrees/` metadata directory associated with each configured
|
|
253
|
+
main worktree:
|
|
254
|
+
|
|
255
|
+
- macOS uses a LaunchAgent with `WatchPaths`.
|
|
256
|
+
- Linux uses a systemd user path unit.
|
|
257
|
+
|
|
258
|
+
When the directory changes, the operating system starts a short-lived Reconciler.
|
|
259
|
+
_It is not a resident daemon and does not poll on a timer_, so its resource usage is
|
|
260
|
+
minimal.
|
|
261
|
+
|
|
262
|
+
The Reconciler:
|
|
263
|
+
|
|
264
|
+
1. Runs `git worktree list --porcelain` to retrieve the actual list of worktrees.
|
|
265
|
+
2. Compares it with `ports.json`, then assigns ports, mounts secrets, and generates
|
|
266
|
+
files for unregistered worktrees.
|
|
267
|
+
|
|
268
|
+
After adding a profile or changing a `main_worktree` path, run this command again:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
wte monitor enable
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
To disable the Monitor, run:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
wte monitor disable
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Afterward, filesystem changes will no longer be monitored.
|
|
281
|
+
|
|
282
|
+
## Asynchronous initialization
|
|
283
|
+
|
|
284
|
+
`wte` can automatically run commands after a worktree is created, allowing the
|
|
285
|
+
environment to be initialized in the background:
|
|
286
|
+
|
|
287
|
+
```yaml
|
|
288
|
+
init:
|
|
289
|
+
- command: npm install
|
|
290
|
+
cwd: frontend-dir # Use "." to run from the worktree root.
|
|
291
|
+
skip_if: node_modules # Skip this command if the file or directory exists.
|
|
292
|
+
|
|
293
|
+
- command: uv sync
|
|
294
|
+
cwd: backend-dir # Use "." to run from the worktree root.
|
|
295
|
+
skip_if: .venv # Skip this command if the file or directory exists.
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
A typical timeline looks like this:
|
|
299
|
+
|
|
300
|
+
```text
|
|
301
|
+
Create a worktree
|
|
302
|
+
→ wte projects the environment and starts npm install in the background
|
|
303
|
+
→ The user describes the task; the AI reads, analyzes, and modifies the code
|
|
304
|
+
→ By the time the user or AI starts the project, dependencies are usually ready
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Asynchronous initialization is started only by the normal `post-checkout` hook.
|
|
308
|
+
Neither `wte sync` nor the Monitor Reconciler runs these commands, preventing manual
|
|
309
|
+
synchronization or background reconciliation from repeatedly starting expensive
|
|
310
|
+
tasks.
|
|
311
|
+
|
|
312
|
+
## Commands supported by `wte`
|
|
313
|
+
|
|
314
|
+
```text
|
|
315
|
+
wte init Create personal configuration and templates, and install core Git hooks
|
|
316
|
+
wte sync Synchronize ports, secrets, and generated files for the current worktree
|
|
317
|
+
wte list List port allocations for worktrees that still exist
|
|
318
|
+
wte doctor Diagnose configuration, profiles, registry, secrets, hooks, and Monitor
|
|
319
|
+
wte monitor enable Install or refresh optional host monitoring
|
|
320
|
+
wte monitor disable Remove host monitoring only, preserving Git hooks
|
|
321
|
+
wte uninstall Remove hooks and the Monitor, preserving configuration and runtime state
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Run `wte sync` from inside the target worktree. It reuses existing ports, recreates
|
|
325
|
+
secret symlinks, and regenerates configuration files.
|
|
326
|
+
You may need it when a worktree is created through an unconventional method.
|
|
327
|
+
|
|
328
|
+
## License
|
|
329
|
+
|
|
330
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
git_worktree_env/__init__.py,sha256=6Yq8i_EGAqESUfMPzQBOcPBNqcogUCW_JsJOQ6kbvv0,58
|
|
2
|
+
git_worktree_env/__main__.py,sha256=exBDURFpBCFo2WGaf2RYs_xCBLylUWaYZkzh3e_viso,148
|
|
3
|
+
git_worktree_env/cli.py,sha256=gy9MIbaCKzV8PeuCbA_jT6Da1tkAtCk5DMf5HkTqmEg,8543
|
|
4
|
+
git_worktree_env/config.py,sha256=K97bg--G8xlFXAbmUMx6kwbysZZYe2wp3Q-ox5KoqEo,4369
|
|
5
|
+
git_worktree_env/hooks.py,sha256=NUf0506cu9qnlq_3fEiUPnhhCbxn-d1JyJQ6GELHm_Q,7185
|
|
6
|
+
git_worktree_env/paths.py,sha256=B2pwcIifkgVCG4aANGOdc2fv5TQs3kYdVnamgFcSgFk,1976
|
|
7
|
+
git_worktree_env/profiles.py,sha256=0VV4mjsOZKm4xbDnGxLZLBwMXaNQDic45jJ_RItXm10,8359
|
|
8
|
+
git_worktree_env/projector.py,sha256=FgF4y6b8q7X3JhCf28q3LhqhQNLx2V94VHQOpQ0r4GM,6232
|
|
9
|
+
git_worktree_env/reconciler.py,sha256=qOp-XT3PP8m39kZUAy8sBpidbOuNJ8I1ZkvWsWZXovQ,9692
|
|
10
|
+
git_worktree_env/registry.py,sha256=mwNRiXzEbcxeLk0hb4pBx5SaXjnEbF2M0wp7M8QNV9A,5852
|
|
11
|
+
git_worktree_env/utils.py,sha256=-XDWw5i4ZSnjckFWbL1WEh1_lgR_u6zr5wKSZVv78ms,2615
|
|
12
|
+
worktree_env-0.2.0.dist-info/METADATA,sha256=QZW0_BYByWSEIndj9MCz-eX4MnFi0A_lAOcZNvua-Fo,11700
|
|
13
|
+
worktree_env-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
worktree_env-0.2.0.dist-info/entry_points.txt,sha256=ssn_gQ9ASkh3eXfPzvTr_-nJmMNoqCHnjVGTA0dbIDM,91
|
|
15
|
+
worktree_env-0.2.0.dist-info/licenses/LICENSE,sha256=acld2wTtDRQwmQJerEphpmlgYDW2jMvsmoA1fcoi3dA,1069
|
|
16
|
+
worktree_env-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shitong Chen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|