worktree-env 0.2.0__tar.gz → 0.3.1__tar.gz
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.
- {worktree_env-0.2.0 → worktree_env-0.3.1}/.github/workflows/publish.yml +0 -6
- {worktree_env-0.2.0 → worktree_env-0.3.1}/CHANGELOG.md +12 -2
- {worktree_env-0.2.0 → worktree_env-0.3.1}/PKG-INFO +11 -9
- {worktree_env-0.2.0 → worktree_env-0.3.1}/README.md +10 -8
- {worktree_env-0.2.0 → worktree_env-0.3.1}/README.zh-CN.md +6 -4
- {worktree_env-0.2.0 → worktree_env-0.3.1}/examples/fullstack.yaml +10 -3
- {worktree_env-0.2.0 → worktree_env-0.3.1}/pyproject.toml +4 -4
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/__init__.py +1 -1
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/__main__.py +1 -1
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/config.py +12 -4
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/profiles.py +29 -0
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/projector.py +22 -6
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/reconciler.py +4 -1
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/conftest.py +1 -1
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_cli.py +2 -2
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_config.py +8 -4
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_hooks.py +3 -3
- worktree_env-0.3.1/tests/test_profiles.py +67 -0
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_projector.py +35 -6
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_reconciler.py +29 -5
- {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_registry.py +4 -4
- {worktree_env-0.2.0 → worktree_env-0.3.1}/uv.lock +1 -1
- worktree_env-0.2.0/migration/git-worktree-env/README.md +0 -43
- worktree_env-0.2.0/migration/git-worktree-env/pyproject.toml +0 -37
- worktree_env-0.2.0/migration/git-worktree-env/src/git_worktree_env_compat/__init__.py +0 -8
- worktree_env-0.2.0/tests/test_profiles.py +0 -34
- {worktree_env-0.2.0 → worktree_env-0.3.1}/.github/workflows/tests.yml +0 -0
- {worktree_env-0.2.0 → worktree_env-0.3.1}/.gitignore +0 -0
- {worktree_env-0.2.0 → worktree_env-0.3.1}/CONTRIBUTING.md +0 -0
- {worktree_env-0.2.0 → worktree_env-0.3.1}/LICENSE +0 -0
- {worktree_env-0.2.0 → worktree_env-0.3.1}/SECURITY.md +0 -0
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/cli.py +0 -0
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/hooks.py +0 -0
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/paths.py +0 -0
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/registry.py +0 -0
- {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/utils.py +0 -0
|
@@ -20,11 +20,5 @@ jobs:
|
|
|
20
20
|
- uses: astral-sh/setup-uv@v6
|
|
21
21
|
- name: Build worktree-env
|
|
22
22
|
run: uv build
|
|
23
|
-
- name: Build git-worktree-env transition package
|
|
24
|
-
if: github.ref == 'refs/tags/v0.2.0'
|
|
25
|
-
run: uv build migration/git-worktree-env --out-dir migration/git-worktree-env/dist
|
|
26
23
|
- name: Publish worktree-env
|
|
27
24
|
run: uv publish dist/* --trusted-publishing always
|
|
28
|
-
- name: Publish git-worktree-env transition package
|
|
29
|
-
if: github.ref == 'refs/tags/v0.2.0'
|
|
30
|
-
run: uv publish migration/git-worktree-env/dist/* --trusted-publishing always
|
|
@@ -4,12 +4,22 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-08-21
|
|
8
|
+
|
|
9
|
+
- Run profile initializers from the Monitor Reconciler so worktrees created by
|
|
10
|
+
sandboxed coding agents and IDEs receive the same setup as hook-created worktrees.
|
|
11
|
+
|
|
12
|
+
## [0.3.0] - 2026-08-21
|
|
13
|
+
|
|
14
|
+
- Changed profile initializers to `command` and `args` string arrays, executed
|
|
15
|
+
directly without an implicit shell.
|
|
16
|
+
- Renamed the Python module from `git_worktree_env` to `worktree_env`.
|
|
17
|
+
|
|
7
18
|
## [0.2.0] - 2026-08-21
|
|
8
19
|
|
|
9
20
|
- Renamed the PyPI package and GitHub repository from `git-worktree-env` to
|
|
10
21
|
`worktree-env`.
|
|
11
|
-
- Kept the `wte` command
|
|
12
|
-
`~/.config/wte/` configuration compatible.
|
|
22
|
+
- Kept the `wte` command and existing `~/.config/wte/` configuration compatible.
|
|
13
23
|
- Added a final `git-worktree-env` transition package and runtime migration guidance.
|
|
14
24
|
|
|
15
25
|
## [0.1.4] - 2026-08-20
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: worktree-env
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Per-worktree ports, secrets, and local environment setup for Git
|
|
5
5
|
Project-URL: Homepage, https://github.com/archcst/worktree-env
|
|
6
6
|
Project-URL: Repository, https://github.com/archcst/worktree-env
|
|
@@ -262,8 +262,8 @@ minimal.
|
|
|
262
262
|
The Reconciler:
|
|
263
263
|
|
|
264
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,
|
|
266
|
-
files for unregistered worktrees.
|
|
265
|
+
2. Compares it with `ports.json`, then assigns ports, mounts secrets, generates
|
|
266
|
+
files, and starts configured initializers for unregistered worktrees.
|
|
267
267
|
|
|
268
268
|
After adding a profile or changing a `main_worktree` path, run this command again:
|
|
269
269
|
|
|
@@ -286,11 +286,13 @@ environment to be initialized in the background:
|
|
|
286
286
|
|
|
287
287
|
```yaml
|
|
288
288
|
init:
|
|
289
|
-
- command: npm
|
|
289
|
+
- command: [npm]
|
|
290
|
+
args: [install]
|
|
290
291
|
cwd: frontend-dir # Use "." to run from the worktree root.
|
|
291
292
|
skip_if: node_modules # Skip this command if the file or directory exists.
|
|
292
293
|
|
|
293
|
-
- command: uv
|
|
294
|
+
- command: [uv]
|
|
295
|
+
args: [sync]
|
|
294
296
|
cwd: backend-dir # Use "." to run from the worktree root.
|
|
295
297
|
skip_if: .venv # Skip this command if the file or directory exists.
|
|
296
298
|
```
|
|
@@ -304,10 +306,10 @@ Create a worktree
|
|
|
304
306
|
→ By the time the user or AI starts the project, dependencies are usually ready
|
|
305
307
|
```
|
|
306
308
|
|
|
307
|
-
Asynchronous initialization is started
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
309
|
+
Asynchronous initialization is started by the normal `post-checkout` hook or by the
|
|
310
|
+
Monitor Reconciler when it discovers an unregistered worktree. The Reconciler does
|
|
311
|
+
not process registered worktrees again, and each command's `skip_if` marker provides
|
|
312
|
+
additional protection against repeated setup. `wte sync` does not run initializers.
|
|
311
313
|
|
|
312
314
|
## Commands supported by `wte`
|
|
313
315
|
|
|
@@ -235,8 +235,8 @@ minimal.
|
|
|
235
235
|
The Reconciler:
|
|
236
236
|
|
|
237
237
|
1. Runs `git worktree list --porcelain` to retrieve the actual list of worktrees.
|
|
238
|
-
2. Compares it with `ports.json`, then assigns ports, mounts secrets,
|
|
239
|
-
files for unregistered worktrees.
|
|
238
|
+
2. Compares it with `ports.json`, then assigns ports, mounts secrets, generates
|
|
239
|
+
files, and starts configured initializers for unregistered worktrees.
|
|
240
240
|
|
|
241
241
|
After adding a profile or changing a `main_worktree` path, run this command again:
|
|
242
242
|
|
|
@@ -259,11 +259,13 @@ environment to be initialized in the background:
|
|
|
259
259
|
|
|
260
260
|
```yaml
|
|
261
261
|
init:
|
|
262
|
-
- command: npm
|
|
262
|
+
- command: [npm]
|
|
263
|
+
args: [install]
|
|
263
264
|
cwd: frontend-dir # Use "." to run from the worktree root.
|
|
264
265
|
skip_if: node_modules # Skip this command if the file or directory exists.
|
|
265
266
|
|
|
266
|
-
- command: uv
|
|
267
|
+
- command: [uv]
|
|
268
|
+
args: [sync]
|
|
267
269
|
cwd: backend-dir # Use "." to run from the worktree root.
|
|
268
270
|
skip_if: .venv # Skip this command if the file or directory exists.
|
|
269
271
|
```
|
|
@@ -277,10 +279,10 @@ Create a worktree
|
|
|
277
279
|
→ By the time the user or AI starts the project, dependencies are usually ready
|
|
278
280
|
```
|
|
279
281
|
|
|
280
|
-
Asynchronous initialization is started
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
Asynchronous initialization is started by the normal `post-checkout` hook or by the
|
|
283
|
+
Monitor Reconciler when it discovers an unregistered worktree. The Reconciler does
|
|
284
|
+
not process registered worktrees again, and each command's `skip_if` marker provides
|
|
285
|
+
additional protection against repeated setup. `wte sync` does not run initializers.
|
|
284
286
|
|
|
285
287
|
## Commands supported by `wte`
|
|
286
288
|
|
|
@@ -188,7 +188,7 @@ wte monitor enable
|
|
|
188
188
|
Reconciler 会:
|
|
189
189
|
|
|
190
190
|
1. 执行 `git worktree list --porcelain` 获取真实 Worktree 列表。
|
|
191
|
-
2. 与 `ports.json` 对比,为尚未注册的 Worktree 分配端口、挂载 Secrets
|
|
191
|
+
2. 与 `ports.json` 对比,为尚未注册的 Worktree 分配端口、挂载 Secrets、生成文件并启动配置的初始化命令。
|
|
192
192
|
|
|
193
193
|
新增 Profile 或修改 `main_worktree` 路径后,需要再次执行:
|
|
194
194
|
|
|
@@ -210,11 +210,13 @@ wte monitor disable
|
|
|
210
210
|
|
|
211
211
|
```yaml
|
|
212
212
|
init:
|
|
213
|
-
- command: npm
|
|
213
|
+
- command: [npm]
|
|
214
|
+
args: [install]
|
|
214
215
|
cwd: frontend-dir # 若在 Worktree 根目录执行,则填写“.”
|
|
215
216
|
skip_if: node_modules # 若该文件或目录存在,则跳过该命令
|
|
216
217
|
|
|
217
|
-
- command: uv
|
|
218
|
+
- command: [uv]
|
|
219
|
+
args: [sync]
|
|
218
220
|
cwd: backend-dir # 若在 Worktree 根目录执行,则填写“.”
|
|
219
221
|
skip_if: .venv # 若该文件或目录存在,则跳过该命令
|
|
220
222
|
```
|
|
@@ -228,7 +230,7 @@ init:
|
|
|
228
230
|
→ 用户或 AI 启动项目时,依赖通常已经准备完成
|
|
229
231
|
```
|
|
230
232
|
|
|
231
|
-
|
|
233
|
+
异步初始化由正常的 `post-checkout` Hook 启动;当 Monitor Reconciler 发现尚未注册的 Worktree 时也会启动。Reconciler 不会重复处理已注册的 Worktree,同时每条命令的 `skip_if` 标记可进一步避免重复初始化。`wte sync` 不会执行初始化命令。
|
|
232
234
|
|
|
233
235
|
## `wte` 支持的命令
|
|
234
236
|
|
|
@@ -11,6 +11,8 @@ ports:
|
|
|
11
11
|
|
|
12
12
|
# Sources stay outside Git. Targets are symlinks inside every linked worktree.
|
|
13
13
|
secrets:
|
|
14
|
+
- source: $HOME/.config/example-app/frontend.env
|
|
15
|
+
target: apps/frontend/.env
|
|
14
16
|
- source: $HOME/.config/example-app/backend.env
|
|
15
17
|
target: apps/backend/.env
|
|
16
18
|
|
|
@@ -26,8 +28,13 @@ writes:
|
|
|
26
28
|
CORS_ORIGIN=http://127.0.0.1:${frontend}
|
|
27
29
|
|
|
28
30
|
# Initializers run in the background only from the post-checkout hook.
|
|
29
|
-
# Each command is trusted local configuration and is executed
|
|
31
|
+
# Each command is trusted local configuration and is executed directly.
|
|
30
32
|
init:
|
|
31
|
-
- command: npm
|
|
32
|
-
|
|
33
|
+
- command: [npm]
|
|
34
|
+
args: [install]
|
|
35
|
+
cwd: apps/frontend
|
|
33
36
|
skip_if: node_modules
|
|
37
|
+
- command: [uv]
|
|
38
|
+
args: [sync]
|
|
39
|
+
cwd: apps/backend
|
|
40
|
+
skip_if: .venv
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "worktree-env"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Per-worktree ports, secrets, and local environment setup for Git"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -37,8 +37,8 @@ Repository = "https://github.com/archcst/worktree-env"
|
|
|
37
37
|
Issues = "https://github.com/archcst/worktree-env/issues"
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
40
|
-
wte = "
|
|
41
|
-
worktree-env = "
|
|
40
|
+
wte = "worktree_env.cli:main"
|
|
41
|
+
worktree-env = "worktree_env.cli:main"
|
|
42
42
|
|
|
43
43
|
[dependency-groups]
|
|
44
44
|
dev = [
|
|
@@ -46,7 +46,7 @@ dev = [
|
|
|
46
46
|
]
|
|
47
47
|
|
|
48
48
|
[tool.hatch.build.targets.wheel]
|
|
49
|
-
packages = ["src/
|
|
49
|
+
packages = ["src/worktree_env"]
|
|
50
50
|
|
|
51
51
|
[tool.pytest.ini_options]
|
|
52
52
|
testpaths = ["tests"]
|
|
@@ -36,6 +36,8 @@ ports:
|
|
|
36
36
|
# Optional local secret files. Sources stay outside Git; targets are replaced
|
|
37
37
|
# with symlinks inside each matched worktree.
|
|
38
38
|
secrets:
|
|
39
|
+
- source: $HOME/.config/example-app/frontend.env
|
|
40
|
+
target: apps/frontend/.env
|
|
39
41
|
- source: $HOME/.config/example-app/backend.env
|
|
40
42
|
target: apps/backend/.env
|
|
41
43
|
|
|
@@ -51,12 +53,18 @@ writes:
|
|
|
51
53
|
PORT=${backend}
|
|
52
54
|
CORS_ORIGIN=http://127.0.0.1:${frontend}
|
|
53
55
|
|
|
54
|
-
# Optional post-checkout
|
|
55
|
-
#
|
|
56
|
+
# Optional worktree initializers started by post-checkout or the Monitor Reconciler.
|
|
57
|
+
# Commands are trusted local configuration executed directly in the background.
|
|
58
|
+
# args are appended to command, and skip_if is resolved relative to cwd.
|
|
56
59
|
init:
|
|
57
|
-
- command: npm
|
|
58
|
-
|
|
60
|
+
- command: [npm]
|
|
61
|
+
args: [install]
|
|
62
|
+
cwd: apps/frontend
|
|
59
63
|
skip_if: node_modules
|
|
64
|
+
- command: [uv]
|
|
65
|
+
args: [sync]
|
|
66
|
+
cwd: apps/backend
|
|
67
|
+
skip_if: .venv
|
|
60
68
|
"""
|
|
61
69
|
|
|
62
70
|
|
|
@@ -210,6 +210,35 @@ def validate_profiles(paths: AppPaths) -> Tuple[List[str], List[str]]:
|
|
|
210
210
|
elif Path(str(target)).is_absolute() or ".." in Path(str(target)).parts:
|
|
211
211
|
errors.append(f"{label}: secrets[{index}].target must stay inside the worktree")
|
|
212
212
|
|
|
213
|
+
initializers = profile.get("init")
|
|
214
|
+
if initializers is None:
|
|
215
|
+
initializers = []
|
|
216
|
+
if not isinstance(initializers, list):
|
|
217
|
+
errors.append(f"{label}: init must be a list")
|
|
218
|
+
else:
|
|
219
|
+
for index, entry in enumerate(initializers):
|
|
220
|
+
if not isinstance(entry, dict):
|
|
221
|
+
errors.append(f"{label}: init[{index}] must be a mapping")
|
|
222
|
+
continue
|
|
223
|
+
command = entry.get("command")
|
|
224
|
+
if (
|
|
225
|
+
not isinstance(command, list)
|
|
226
|
+
or not command
|
|
227
|
+
or not all(isinstance(value, str) for value in command)
|
|
228
|
+
or not command[0].strip()
|
|
229
|
+
):
|
|
230
|
+
errors.append(
|
|
231
|
+
f"{label}: init[{index}].command must be a non-empty string list"
|
|
232
|
+
)
|
|
233
|
+
args = entry.get("args", [])
|
|
234
|
+
if not isinstance(args, list) or not all(
|
|
235
|
+
isinstance(value, str) for value in args
|
|
236
|
+
):
|
|
237
|
+
errors.append(f"{label}: init[{index}].args must be a string list")
|
|
238
|
+
cwd = entry.get("cwd")
|
|
239
|
+
if not isinstance(cwd, str) or not cwd.strip():
|
|
240
|
+
errors.append(f"{label}: init[{index}].cwd is required")
|
|
241
|
+
|
|
213
242
|
if not profiles:
|
|
214
243
|
warnings.append(f"no profiles found in {paths.profiles}")
|
|
215
244
|
return errors, warnings
|
{worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/projector.py
RENAMED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import shlex
|
|
6
7
|
import subprocess
|
|
7
8
|
import tempfile
|
|
8
9
|
from dataclasses import dataclass
|
|
@@ -90,11 +91,26 @@ def _init_entries(profile: Profile) -> List[Dict[str, Any]]:
|
|
|
90
91
|
def run_initializers(profile: Profile, root: Path) -> None:
|
|
91
92
|
"""Start missing dependency setup tasks without blocking Git checkout."""
|
|
92
93
|
for index, item in enumerate(_init_entries(profile)):
|
|
93
|
-
command =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
command = item.get("command")
|
|
95
|
+
args = item.get("args", [])
|
|
96
|
+
cwd_raw = item.get("cwd")
|
|
97
|
+
if (
|
|
98
|
+
not isinstance(command, list)
|
|
99
|
+
or not command
|
|
100
|
+
or not all(isinstance(value, str) for value in command)
|
|
101
|
+
or not command[0].strip()
|
|
102
|
+
):
|
|
103
|
+
log(f"initializer skipped; command must be a non-empty string list: {item}")
|
|
97
104
|
continue
|
|
105
|
+
if not isinstance(args, list) or not all(
|
|
106
|
+
isinstance(value, str) for value in args
|
|
107
|
+
):
|
|
108
|
+
log(f"initializer skipped; args must be a string list: {item}")
|
|
109
|
+
continue
|
|
110
|
+
if not isinstance(cwd_raw, str) or not cwd_raw.strip():
|
|
111
|
+
log(f"initializer skipped; cwd is required: {item}")
|
|
112
|
+
continue
|
|
113
|
+
argv = command + args
|
|
98
114
|
cwd = expand_profile_path(cwd_raw, profile, root=root).resolve()
|
|
99
115
|
if not cwd.is_dir():
|
|
100
116
|
log(f"initializer skipped; directory does not exist: {cwd}")
|
|
@@ -108,7 +124,7 @@ def run_initializers(profile: Profile, root: Path) -> None:
|
|
|
108
124
|
output = log_path.open("a")
|
|
109
125
|
try:
|
|
110
126
|
subprocess.Popen(
|
|
111
|
-
|
|
127
|
+
argv,
|
|
112
128
|
cwd=str(cwd),
|
|
113
129
|
stdout=output,
|
|
114
130
|
stderr=subprocess.STDOUT,
|
|
@@ -116,7 +132,7 @@ def run_initializers(profile: Profile, root: Path) -> None:
|
|
|
116
132
|
)
|
|
117
133
|
finally:
|
|
118
134
|
output.close()
|
|
119
|
-
log(f"initializer started: {
|
|
135
|
+
log(f"initializer started: {shlex.join(argv)} -> {log_path}")
|
|
120
136
|
|
|
121
137
|
|
|
122
138
|
def apply_worktree(
|
{worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/reconciler.py
RENAMED
|
@@ -110,7 +110,10 @@ def reconcile_once(paths: AppPaths) -> ReconcileResult:
|
|
|
110
110
|
# Git may publish common-dir metadata before checkout is complete.
|
|
111
111
|
if worktree_root(candidate) != candidate:
|
|
112
112
|
raise WteError(f"worktree root is not stable yet: {candidate}")
|
|
113
|
-
|
|
113
|
+
# The reconciler is the fallback for sandboxed Git clients that
|
|
114
|
+
# cannot invoke the host's post-checkout hook, so it must perform
|
|
115
|
+
# the same asynchronous initialization for a new worktree.
|
|
116
|
+
result = apply_worktree(paths, candidate, setup=True)
|
|
114
117
|
if result is None:
|
|
115
118
|
continue
|
|
116
119
|
print_apply_result(result)
|
|
@@ -2,7 +2,7 @@ import argparse
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from worktree_env.cli import _build_parser, _warn_if_legacy_distribution_installed
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def test_public_cli_contains_only_the_six_user_commands():
|
|
@@ -37,7 +37,7 @@ def test_sync_does_not_accept_a_worktree_path():
|
|
|
37
37
|
|
|
38
38
|
def test_legacy_distribution_prints_migration_instructions(monkeypatch, capsys):
|
|
39
39
|
monkeypatch.setattr(
|
|
40
|
-
"
|
|
40
|
+
"worktree_env.cli.metadata.distribution", lambda _name: object()
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
_warn_if_legacy_distribution_installed()
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from worktree_env.config import (
|
|
4
4
|
initialize_config,
|
|
5
5
|
initialize_profile_template,
|
|
6
6
|
load_port_pool,
|
|
7
7
|
)
|
|
8
|
-
from
|
|
9
|
-
from
|
|
8
|
+
from worktree_env.paths import AppPaths
|
|
9
|
+
from worktree_env.utils import WteError
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def test_init_generates_documented_port_range(tmp_path, monkeypatch):
|
|
@@ -32,7 +32,11 @@ def test_init_template_is_commented_and_not_an_active_yaml_profile(app_paths):
|
|
|
32
32
|
assert "# Port IDs form one contiguous block" in text
|
|
33
33
|
assert "# Optional local secret files" in text
|
|
34
34
|
assert "# Optional generated files" in text
|
|
35
|
-
assert "# Optional
|
|
35
|
+
assert "# Optional worktree initializers" in text
|
|
36
|
+
assert "$HOME/.config/example-app/frontend.env" in text
|
|
37
|
+
assert "$HOME/.config/example-app/backend.env" in text
|
|
38
|
+
assert "cwd: apps/frontend" in text
|
|
39
|
+
assert "cwd: apps/backend" in text
|
|
36
40
|
assert "wte monitor enable" in text
|
|
37
41
|
|
|
38
42
|
|
|
@@ -2,8 +2,8 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from
|
|
6
|
-
from
|
|
5
|
+
from worktree_env.hooks import _dispatcher_script, install_hooks
|
|
6
|
+
from worktree_env.utils import WteError
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def test_dispatcher_runs_wte_only_for_post_checkout():
|
|
@@ -22,7 +22,7 @@ def test_install_refuses_to_replace_an_existing_global_hooks_path(
|
|
|
22
22
|
existing = tmp_path / "other-hooks"
|
|
23
23
|
existing.mkdir()
|
|
24
24
|
monkeypatch.setattr(
|
|
25
|
-
"
|
|
25
|
+
"worktree_env.hooks.current_hooks_path", lambda: str(existing)
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
with pytest.raises(WteError, match="wte did not change it"):
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from worktree_env.profiles import find_profile, main_worktree_root, validate_profiles
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_linked_worktree_matches_its_main_profile(app_paths, git_worktrees):
|
|
7
|
+
main, linked = git_worktrees
|
|
8
|
+
(app_paths.profiles / "example.yaml").write_text(
|
|
9
|
+
"name: example\n"
|
|
10
|
+
"match:\n"
|
|
11
|
+
f" main_worktree: {main}\n"
|
|
12
|
+
"ports:\n"
|
|
13
|
+
" - id: web\n"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
profile = find_profile(app_paths, linked)
|
|
17
|
+
|
|
18
|
+
assert profile is not None
|
|
19
|
+
assert profile["name"] == "example"
|
|
20
|
+
assert main_worktree_root(linked) == main
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_validation_rejects_duplicate_main_worktrees(app_paths, git_worktrees):
|
|
24
|
+
main, _ = git_worktrees
|
|
25
|
+
for name in ("one", "two"):
|
|
26
|
+
(app_paths.profiles / f"{name}.yaml").write_text(
|
|
27
|
+
f"name: {name}\n"
|
|
28
|
+
f"match:\n main_worktree: {main}\n"
|
|
29
|
+
"ports:\n - id: web\n"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
errors, _warnings = validate_profiles(app_paths)
|
|
33
|
+
|
|
34
|
+
assert any("also configured" in error for error in errors)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_validation_accepts_initializer_command_and_args_lists(app_paths, tmp_path):
|
|
38
|
+
(app_paths.profiles / "example.yaml").write_text(
|
|
39
|
+
"name: example\n"
|
|
40
|
+
f"match:\n main_worktree: {tmp_path}\n"
|
|
41
|
+
"ports:\n - id: web\n"
|
|
42
|
+
"init:\n"
|
|
43
|
+
" - command: [npm]\n"
|
|
44
|
+
" args: [install]\n"
|
|
45
|
+
" cwd: .\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
errors, _warnings = validate_profiles(app_paths)
|
|
49
|
+
|
|
50
|
+
assert not errors
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_validation_rejects_initializer_string_command(app_paths, tmp_path):
|
|
54
|
+
(app_paths.profiles / "example.yaml").write_text(
|
|
55
|
+
"name: example\n"
|
|
56
|
+
f"match:\n main_worktree: {tmp_path}\n"
|
|
57
|
+
"ports:\n - id: web\n"
|
|
58
|
+
"init:\n"
|
|
59
|
+
" - command: npm install\n"
|
|
60
|
+
" cwd: .\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
errors, _warnings = validate_profiles(app_paths)
|
|
64
|
+
|
|
65
|
+
assert any(
|
|
66
|
+
"init[0].command must be a non-empty string list" in error for error in errors
|
|
67
|
+
)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
from
|
|
3
|
+
from worktree_env.projector import apply_worktree, run_initializers
|
|
4
|
+
from worktree_env.registry import load_registry
|
|
5
|
+
from worktree_env.utils import WteError
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def test_projection_links_secrets_and_writes_ports(app_paths, git_worktrees, tmp_path, monkeypatch):
|
|
@@ -25,7 +25,7 @@ def test_projection_links_secrets_and_writes_ports(app_paths, git_worktrees, tmp
|
|
|
25
25
|
generated_path = linked / "app/.env.development"
|
|
26
26
|
generated_path.parent.mkdir(parents=True)
|
|
27
27
|
generated_path.write_text("KEEP=old\n")
|
|
28
|
-
monkeypatch.setattr("
|
|
28
|
+
monkeypatch.setattr("worktree_env.registry.port_is_free", lambda _port: True)
|
|
29
29
|
|
|
30
30
|
result = apply_worktree(app_paths, linked)
|
|
31
31
|
|
|
@@ -39,6 +39,35 @@ def test_projection_links_secrets_and_writes_ports(app_paths, git_worktrees, tmp
|
|
|
39
39
|
assert load_registry(app_paths)[str(linked)]["profile"] == "example"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def test_initializers_execute_command_and_args_without_a_shell(
|
|
43
|
+
tmp_path, monkeypatch, capsys
|
|
44
|
+
):
|
|
45
|
+
calls = []
|
|
46
|
+
profile = {
|
|
47
|
+
"name": "example",
|
|
48
|
+
"init": [
|
|
49
|
+
{
|
|
50
|
+
"command": ["tool", "subcommand"],
|
|
51
|
+
"args": ["literal;value", "two words"],
|
|
52
|
+
"cwd": ".",
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
monkeypatch.setattr(
|
|
57
|
+
"worktree_env.projector.tempfile.gettempdir", lambda: str(tmp_path)
|
|
58
|
+
)
|
|
59
|
+
monkeypatch.setattr(
|
|
60
|
+
"worktree_env.projector.subprocess.Popen",
|
|
61
|
+
lambda argv, **kwargs: calls.append((argv, kwargs)),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
run_initializers(profile, tmp_path)
|
|
65
|
+
|
|
66
|
+
assert calls[0][0] == ["tool", "subcommand", "literal;value", "two words"]
|
|
67
|
+
assert calls[0][1]["cwd"] == str(tmp_path.resolve())
|
|
68
|
+
assert "tool subcommand 'literal;value' 'two words'" in capsys.readouterr().err
|
|
69
|
+
|
|
70
|
+
|
|
42
71
|
def test_failed_projection_does_not_commit_a_registry_entry(
|
|
43
72
|
app_paths, git_worktrees, monkeypatch
|
|
44
73
|
):
|
|
@@ -49,12 +78,12 @@ def test_failed_projection_does_not_commit_a_registry_entry(
|
|
|
49
78
|
"ports:\n - id: web\n"
|
|
50
79
|
"writes:\n - path: generated.env\n body: PORT=${web}\n"
|
|
51
80
|
)
|
|
52
|
-
monkeypatch.setattr("
|
|
81
|
+
monkeypatch.setattr("worktree_env.registry.port_is_free", lambda _port: True)
|
|
53
82
|
|
|
54
83
|
def fail_writes(*_args):
|
|
55
84
|
raise WteError("write failed")
|
|
56
85
|
|
|
57
|
-
monkeypatch.setattr("
|
|
86
|
+
monkeypatch.setattr("worktree_env.projector.apply_writes", fail_writes)
|
|
58
87
|
|
|
59
88
|
with pytest.raises(WteError, match="write failed"):
|
|
60
89
|
apply_worktree(app_paths, linked)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import plistlib
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
from
|
|
4
|
+
from worktree_env.reconciler import (
|
|
5
5
|
LAUNCHD_LABEL,
|
|
6
6
|
_listed_worktrees,
|
|
7
7
|
_write_launch_agent,
|
|
@@ -9,7 +9,7 @@ from git_worktree_env.reconciler import (
|
|
|
9
9
|
discover_watch_paths,
|
|
10
10
|
reconcile_once,
|
|
11
11
|
)
|
|
12
|
-
from
|
|
12
|
+
from worktree_env.registry import load_registry
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def test_git_porcelain_discovery_returns_main_and_linked_worktrees(git_worktrees):
|
|
@@ -41,7 +41,7 @@ def test_reconciler_projects_only_unregistered_worktrees(
|
|
|
41
41
|
"ports:\n - id: web\n"
|
|
42
42
|
"writes:\n - path: generated.env\n body: PORT=${web}\n"
|
|
43
43
|
)
|
|
44
|
-
monkeypatch.setattr("
|
|
44
|
+
monkeypatch.setattr("worktree_env.registry.port_is_free", lambda _port: True)
|
|
45
45
|
|
|
46
46
|
first = reconcile_once(app_paths)
|
|
47
47
|
second = reconcile_once(app_paths)
|
|
@@ -55,13 +55,37 @@ def test_reconciler_projects_only_unregistered_worktrees(
|
|
|
55
55
|
assert (linked / "generated.env").exists()
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def test_reconciler_runs_initializers_for_unregistered_worktrees(
|
|
59
|
+
app_paths, git_worktrees, monkeypatch
|
|
60
|
+
):
|
|
61
|
+
main, linked = git_worktrees
|
|
62
|
+
(app_paths.profiles / "example.yaml").write_text(
|
|
63
|
+
"name: example\n"
|
|
64
|
+
f"match:\n main_worktree: {main}\n"
|
|
65
|
+
"ports:\n - id: web\n"
|
|
66
|
+
"init:\n - command: [tool]\n cwd: .\n"
|
|
67
|
+
)
|
|
68
|
+
monkeypatch.setattr("worktree_env.registry.port_is_free", lambda _port: True)
|
|
69
|
+
initialized = []
|
|
70
|
+
monkeypatch.setattr(
|
|
71
|
+
"worktree_env.projector.run_initializers",
|
|
72
|
+
lambda _profile, root: initialized.append(root),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
reconcile_once(app_paths)
|
|
76
|
+
reconcile_once(app_paths)
|
|
77
|
+
|
|
78
|
+
assert set(initialized) == {main.resolve(), linked.resolve()}
|
|
79
|
+
assert len(initialized) == 2
|
|
80
|
+
|
|
81
|
+
|
|
58
82
|
def test_launch_agent_watches_common_git_metadata(
|
|
59
83
|
app_paths, tmp_path, monkeypatch
|
|
60
84
|
):
|
|
61
85
|
agent = tmp_path / "wte.plist"
|
|
62
86
|
watched = (tmp_path / "repo/.git/worktrees",)
|
|
63
87
|
monkeypatch.setattr(
|
|
64
|
-
"
|
|
88
|
+
"worktree_env.reconciler._launch_agent_path", lambda: agent
|
|
65
89
|
)
|
|
66
90
|
|
|
67
91
|
_write_launch_agent(app_paths, Path("/opt/bin/wte"), watched)
|
|
@@ -77,7 +101,7 @@ def test_systemd_path_unit_uses_one_shot_reconciliation(tmp_path, monkeypatch):
|
|
|
77
101
|
unit_dir = tmp_path / "systemd/user"
|
|
78
102
|
watched = (tmp_path / "repo/.git/worktrees",)
|
|
79
103
|
monkeypatch.setattr(
|
|
80
|
-
"
|
|
104
|
+
"worktree_env.reconciler._systemd_user_dir", lambda: unit_dir
|
|
81
105
|
)
|
|
82
106
|
|
|
83
107
|
service, path_unit = _write_systemd_units(Path("/opt/bin/wte"), watched)
|
|
@@ -3,14 +3,14 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
6
|
-
from
|
|
7
|
-
from
|
|
6
|
+
from worktree_env.config import PortPool
|
|
7
|
+
from worktree_env.registry import (
|
|
8
8
|
allocate_ports,
|
|
9
9
|
load_registry,
|
|
10
10
|
prune_registry,
|
|
11
11
|
save_registry,
|
|
12
12
|
)
|
|
13
|
-
from
|
|
13
|
+
from worktree_env.utils import WteError
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def test_allocation_is_sticky_and_contiguous(app_paths, tmp_path, monkeypatch):
|
|
@@ -21,7 +21,7 @@ def test_allocation_is_sticky_and_contiguous(app_paths, tmp_path, monkeypatch):
|
|
|
21
21
|
"_file": str(tmp_path / "example.yaml"),
|
|
22
22
|
"ports": [{"id": "frontend"}, {"id": "backend"}],
|
|
23
23
|
}
|
|
24
|
-
monkeypatch.setattr("
|
|
24
|
+
monkeypatch.setattr("worktree_env.registry.port_is_free", lambda _port: True)
|
|
25
25
|
|
|
26
26
|
start, ports = allocate_ports(profile, root, {}, PortPool(42000, 42010))
|
|
27
27
|
registry = {
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# `git-worktree-env` has moved
|
|
2
|
-
|
|
3
|
-
> **This package has been renamed to [`worktree-env`](https://pypi.org/project/worktree-env/).**
|
|
4
|
-
|
|
5
|
-
This is the final transition release of `git-worktree-env`. It installs the renamed
|
|
6
|
-
package so existing users can continue to run `wte`, but future releases will only be
|
|
7
|
-
published as `worktree-env`.
|
|
8
|
-
|
|
9
|
-
## Migrate an existing installation
|
|
10
|
-
|
|
11
|
-
For an installation managed by `uv`:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
uv tool uninstall git-worktree-env
|
|
15
|
-
uv tool install worktree-env
|
|
16
|
-
wte init
|
|
17
|
-
# If you previously enabled the optional Monitor:
|
|
18
|
-
wte monitor enable
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
For an installation managed by `pipx`:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
pipx uninstall git-worktree-env
|
|
25
|
-
pipx install worktree-env
|
|
26
|
-
wte init
|
|
27
|
-
# If you previously enabled the optional Monitor:
|
|
28
|
-
wte monitor enable
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
For an installation managed by `pip`:
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
python -m pip uninstall -y git-worktree-env
|
|
35
|
-
python -m pip install --upgrade --force-reinstall worktree-env
|
|
36
|
-
wte init
|
|
37
|
-
# If you previously enabled the optional Monitor:
|
|
38
|
-
wte monitor enable
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Running `wte init` refreshes the Git hook's absolute executable path. The `wte`
|
|
42
|
-
command, the `git_worktree_env` Python import, and all existing configuration under
|
|
43
|
-
`~/.config/wte/` remain compatible.
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["hatchling>=1.26"]
|
|
3
|
-
build-backend = "hatchling.build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "git-worktree-env"
|
|
7
|
-
version = "0.2.0"
|
|
8
|
-
description = "Transition package for the renamed worktree-env project"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.9"
|
|
11
|
-
license = "MIT"
|
|
12
|
-
authors = [
|
|
13
|
-
{ name = "Shitong Chen" },
|
|
14
|
-
]
|
|
15
|
-
classifiers = [
|
|
16
|
-
"Development Status :: 7 - Inactive",
|
|
17
|
-
"Environment :: Console",
|
|
18
|
-
"License :: OSI Approved :: MIT License",
|
|
19
|
-
"Operating System :: MacOS",
|
|
20
|
-
"Operating System :: POSIX :: Linux",
|
|
21
|
-
"Programming Language :: Python :: 3",
|
|
22
|
-
]
|
|
23
|
-
dependencies = [
|
|
24
|
-
"worktree-env>=0.2.0",
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
[project.urls]
|
|
28
|
-
Homepage = "https://github.com/archcst/worktree-env"
|
|
29
|
-
Repository = "https://github.com/archcst/worktree-env"
|
|
30
|
-
Issues = "https://github.com/archcst/worktree-env/issues"
|
|
31
|
-
|
|
32
|
-
[project.scripts]
|
|
33
|
-
wte = "git_worktree_env_compat:main"
|
|
34
|
-
git-worktree-env = "git_worktree_env_compat:main"
|
|
35
|
-
|
|
36
|
-
[tool.hatch.build.targets.wheel]
|
|
37
|
-
packages = ["src/git_worktree_env_compat"]
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
|
-
from git_worktree_env.profiles import find_profile, main_worktree_root, validate_profiles
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_linked_worktree_matches_its_main_profile(app_paths, git_worktrees):
|
|
7
|
-
main, linked = git_worktrees
|
|
8
|
-
(app_paths.profiles / "example.yaml").write_text(
|
|
9
|
-
"name: example\n"
|
|
10
|
-
"match:\n"
|
|
11
|
-
f" main_worktree: {main}\n"
|
|
12
|
-
"ports:\n"
|
|
13
|
-
" - id: web\n"
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
profile = find_profile(app_paths, linked)
|
|
17
|
-
|
|
18
|
-
assert profile is not None
|
|
19
|
-
assert profile["name"] == "example"
|
|
20
|
-
assert main_worktree_root(linked) == main
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_validation_rejects_duplicate_main_worktrees(app_paths, git_worktrees):
|
|
24
|
-
main, _ = git_worktrees
|
|
25
|
-
for name in ("one", "two"):
|
|
26
|
-
(app_paths.profiles / f"{name}.yaml").write_text(
|
|
27
|
-
f"name: {name}\n"
|
|
28
|
-
f"match:\n main_worktree: {main}\n"
|
|
29
|
-
"ports:\n - id: web\n"
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
errors, _warnings = validate_profiles(app_paths)
|
|
33
|
-
|
|
34
|
-
assert any("also configured" in error for error in errors)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|