worktree-env 0.3.0__tar.gz → 0.4.0__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.3.0 → worktree_env-0.4.0}/CHANGELOG.md +10 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/PKG-INFO +20 -16
- {worktree_env-0.3.0 → worktree_env-0.4.0}/README.md +19 -15
- {worktree_env-0.3.0 → worktree_env-0.4.0}/README.zh-CN.md +14 -11
- {worktree_env-0.3.0 → worktree_env-0.4.0}/examples/fullstack.yaml +7 -7
- {worktree_env-0.3.0 → worktree_env-0.4.0}/pyproject.toml +1 -1
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/__init__.py +1 -1
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/cli.py +2 -2
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/config.py +24 -20
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/profiles.py +87 -31
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/projector.py +6 -6
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/reconciler.py +4 -1
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/registry.py +2 -2
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_config.py +18 -3
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_profiles.py +4 -4
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_projector.py +28 -7
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_reconciler.py +24 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/uv.lock +1 -1
- {worktree_env-0.3.0 → worktree_env-0.4.0}/.github/workflows/publish.yml +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/.github/workflows/tests.yml +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/.gitignore +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/CONTRIBUTING.md +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/LICENSE +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/SECURITY.md +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/__main__.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/hooks.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/paths.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/utils.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/conftest.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_cli.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_hooks.py +0 -0
- {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_registry.py +0 -0
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2026-08-22
|
|
8
|
+
|
|
9
|
+
- Standardized user-facing configuration keys on hyphenated names while keeping
|
|
10
|
+
underscore-based profile and port-range keys as backward-compatible aliases.
|
|
11
|
+
|
|
12
|
+
## [0.3.1] - 2026-08-21
|
|
13
|
+
|
|
14
|
+
- Run profile initializers from the Monitor Reconciler so worktrees created by
|
|
15
|
+
sandboxed coding agents and IDEs receive the same setup as hook-created worktrees.
|
|
16
|
+
|
|
7
17
|
## [0.3.0] - 2026-08-21
|
|
8
18
|
|
|
9
19
|
- Changed profile initializers to `command` and `args` string arrays, executed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: worktree-env
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -180,7 +180,7 @@ Set `WTE_CONFIG_HOME` to change this directory. When it is not set,
|
|
|
180
180
|
The machine-wide port range is configured in `~/.config/wte/config.yaml`:
|
|
181
181
|
|
|
182
182
|
```yaml
|
|
183
|
-
|
|
183
|
+
port-range:
|
|
184
184
|
start: 20000
|
|
185
185
|
end: 29999
|
|
186
186
|
```
|
|
@@ -204,15 +204,15 @@ name: example-project
|
|
|
204
204
|
|
|
205
205
|
match:
|
|
206
206
|
# Points to the project's main worktree directory.
|
|
207
|
-
|
|
207
|
+
main-worktree: $HOME/code/example-app
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
port-claims:
|
|
210
210
|
# Names of the ports to request. Add as many as the project needs;
|
|
211
211
|
# each id must be unique within the profile.
|
|
212
212
|
- id: frontend_port
|
|
213
213
|
- id: backend_port
|
|
214
214
|
|
|
215
|
-
|
|
215
|
+
link-files:
|
|
216
216
|
# Environment files shared through symlinks.
|
|
217
217
|
# source points to the original environment file, while target is a path
|
|
218
218
|
# relative to the worktree root.
|
|
@@ -221,7 +221,7 @@ secrets:
|
|
|
221
221
|
- source: $HOME/path/to/your/backend.env
|
|
222
222
|
target: backend-dir/.env
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
write-files:
|
|
225
225
|
# Frontend configuration:
|
|
226
226
|
- path: frontend-dir/.env.development
|
|
227
227
|
body: |
|
|
@@ -239,6 +239,10 @@ writes:
|
|
|
239
239
|
>
|
|
240
240
|
> After a worktree directory is deleted, its ports are reclaimed the next time `wte`
|
|
241
241
|
> is triggered.
|
|
242
|
+
>
|
|
243
|
+
> Configuration generated by older versions remains supported. The legacy keys
|
|
244
|
+
> `port_range`, `main_worktree`, `ports`, `secrets`, `writes`, `init`, and `skip_if`
|
|
245
|
+
> are aliases for the hyphenated keys shown above.
|
|
242
246
|
|
|
243
247
|
## Monitor
|
|
244
248
|
|
|
@@ -262,10 +266,10 @@ minimal.
|
|
|
262
266
|
The Reconciler:
|
|
263
267
|
|
|
264
268
|
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.
|
|
269
|
+
2. Compares it with `ports.json`, then assigns ports, mounts secrets, generates
|
|
270
|
+
files, and starts configured initializers for unregistered worktrees.
|
|
267
271
|
|
|
268
|
-
After adding a profile or changing a `
|
|
272
|
+
After adding a profile or changing a `main-worktree` path, run this command again:
|
|
269
273
|
|
|
270
274
|
```bash
|
|
271
275
|
wte monitor enable
|
|
@@ -285,16 +289,16 @@ Afterward, filesystem changes will no longer be monitored.
|
|
|
285
289
|
environment to be initialized in the background:
|
|
286
290
|
|
|
287
291
|
```yaml
|
|
288
|
-
|
|
292
|
+
setup-commands:
|
|
289
293
|
- command: [npm]
|
|
290
294
|
args: [install]
|
|
291
295
|
cwd: frontend-dir # Use "." to run from the worktree root.
|
|
292
|
-
|
|
296
|
+
skip-if: node_modules # Skip this command if the file or directory exists.
|
|
293
297
|
|
|
294
298
|
- command: [uv]
|
|
295
299
|
args: [sync]
|
|
296
300
|
cwd: backend-dir # Use "." to run from the worktree root.
|
|
297
|
-
|
|
301
|
+
skip-if: .venv # Skip this command if the file or directory exists.
|
|
298
302
|
```
|
|
299
303
|
|
|
300
304
|
A typical timeline looks like this:
|
|
@@ -306,10 +310,10 @@ Create a worktree
|
|
|
306
310
|
→ By the time the user or AI starts the project, dependencies are usually ready
|
|
307
311
|
```
|
|
308
312
|
|
|
309
|
-
Asynchronous initialization is started
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
Asynchronous initialization is started by the normal `post-checkout` hook or by the
|
|
314
|
+
Monitor Reconciler when it discovers an unregistered worktree. The Reconciler does
|
|
315
|
+
not process registered worktrees again, and each command's `skip-if` marker provides
|
|
316
|
+
additional protection against repeated setup. `wte sync` does not run initializers.
|
|
313
317
|
|
|
314
318
|
## Commands supported by `wte`
|
|
315
319
|
|
|
@@ -153,7 +153,7 @@ Set `WTE_CONFIG_HOME` to change this directory. When it is not set,
|
|
|
153
153
|
The machine-wide port range is configured in `~/.config/wte/config.yaml`:
|
|
154
154
|
|
|
155
155
|
```yaml
|
|
156
|
-
|
|
156
|
+
port-range:
|
|
157
157
|
start: 20000
|
|
158
158
|
end: 29999
|
|
159
159
|
```
|
|
@@ -177,15 +177,15 @@ name: example-project
|
|
|
177
177
|
|
|
178
178
|
match:
|
|
179
179
|
# Points to the project's main worktree directory.
|
|
180
|
-
|
|
180
|
+
main-worktree: $HOME/code/example-app
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
port-claims:
|
|
183
183
|
# Names of the ports to request. Add as many as the project needs;
|
|
184
184
|
# each id must be unique within the profile.
|
|
185
185
|
- id: frontend_port
|
|
186
186
|
- id: backend_port
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
link-files:
|
|
189
189
|
# Environment files shared through symlinks.
|
|
190
190
|
# source points to the original environment file, while target is a path
|
|
191
191
|
# relative to the worktree root.
|
|
@@ -194,7 +194,7 @@ secrets:
|
|
|
194
194
|
- source: $HOME/path/to/your/backend.env
|
|
195
195
|
target: backend-dir/.env
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
write-files:
|
|
198
198
|
# Frontend configuration:
|
|
199
199
|
- path: frontend-dir/.env.development
|
|
200
200
|
body: |
|
|
@@ -212,6 +212,10 @@ writes:
|
|
|
212
212
|
>
|
|
213
213
|
> After a worktree directory is deleted, its ports are reclaimed the next time `wte`
|
|
214
214
|
> is triggered.
|
|
215
|
+
>
|
|
216
|
+
> Configuration generated by older versions remains supported. The legacy keys
|
|
217
|
+
> `port_range`, `main_worktree`, `ports`, `secrets`, `writes`, `init`, and `skip_if`
|
|
218
|
+
> are aliases for the hyphenated keys shown above.
|
|
215
219
|
|
|
216
220
|
## Monitor
|
|
217
221
|
|
|
@@ -235,10 +239,10 @@ minimal.
|
|
|
235
239
|
The Reconciler:
|
|
236
240
|
|
|
237
241
|
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.
|
|
242
|
+
2. Compares it with `ports.json`, then assigns ports, mounts secrets, generates
|
|
243
|
+
files, and starts configured initializers for unregistered worktrees.
|
|
240
244
|
|
|
241
|
-
After adding a profile or changing a `
|
|
245
|
+
After adding a profile or changing a `main-worktree` path, run this command again:
|
|
242
246
|
|
|
243
247
|
```bash
|
|
244
248
|
wte monitor enable
|
|
@@ -258,16 +262,16 @@ Afterward, filesystem changes will no longer be monitored.
|
|
|
258
262
|
environment to be initialized in the background:
|
|
259
263
|
|
|
260
264
|
```yaml
|
|
261
|
-
|
|
265
|
+
setup-commands:
|
|
262
266
|
- command: [npm]
|
|
263
267
|
args: [install]
|
|
264
268
|
cwd: frontend-dir # Use "." to run from the worktree root.
|
|
265
|
-
|
|
269
|
+
skip-if: node_modules # Skip this command if the file or directory exists.
|
|
266
270
|
|
|
267
271
|
- command: [uv]
|
|
268
272
|
args: [sync]
|
|
269
273
|
cwd: backend-dir # Use "." to run from the worktree root.
|
|
270
|
-
|
|
274
|
+
skip-if: .venv # Skip this command if the file or directory exists.
|
|
271
275
|
```
|
|
272
276
|
|
|
273
277
|
A typical timeline looks like this:
|
|
@@ -279,10 +283,10 @@ Create a worktree
|
|
|
279
283
|
→ By the time the user or AI starts the project, dependencies are usually ready
|
|
280
284
|
```
|
|
281
285
|
|
|
282
|
-
Asynchronous initialization is started
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
+
Asynchronous initialization is started by the normal `post-checkout` hook or by the
|
|
287
|
+
Monitor Reconciler when it discovers an unregistered worktree. The Reconciler does
|
|
288
|
+
not process registered worktrees again, and each command's `skip-if` marker provides
|
|
289
|
+
additional protection against repeated setup. `wte sync` does not run initializers.
|
|
286
290
|
|
|
287
291
|
## Commands supported by `wte`
|
|
288
292
|
|
|
@@ -114,7 +114,7 @@ wte init
|
|
|
114
114
|
全机端口范围位于 `~/.config/wte/config.yaml`:
|
|
115
115
|
|
|
116
116
|
```yaml
|
|
117
|
-
|
|
117
|
+
port-range:
|
|
118
118
|
start: 20000
|
|
119
119
|
end: 29999
|
|
120
120
|
```
|
|
@@ -137,14 +137,14 @@ name: example-project
|
|
|
137
137
|
|
|
138
138
|
match:
|
|
139
139
|
# 指向该项目的 main worktree 目录。
|
|
140
|
-
|
|
140
|
+
main-worktree: $HOME/code/example-app
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
port-claims:
|
|
143
143
|
# 需要申请的端口的名称,项目需要多少就申请多少,id 在同一个 profile 中应保持唯一
|
|
144
144
|
- id: frontend_port
|
|
145
145
|
- id: backend_port
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
link-files:
|
|
148
148
|
# 软链接共享的环境变量
|
|
149
149
|
# source 指向原始环境变量文件,target 是目标位置,路径是基于 worktree 根目录的相对路径。
|
|
150
150
|
- source: $HOME/path/to/your/frontend.env
|
|
@@ -152,7 +152,7 @@ secrets:
|
|
|
152
152
|
- source: $HOME/path/to/your/backend.env
|
|
153
153
|
target: backend-dir/.env
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
write-files:
|
|
156
156
|
# 前端配置:
|
|
157
157
|
- path: frontend-dir/.env.development
|
|
158
158
|
body: |
|
|
@@ -168,6 +168,9 @@ writes:
|
|
|
168
168
|
> 该示例适用于前后端均可加载 `.env.{env name}` 的情况,请根据具体项目环境变量加载方式自行修改。
|
|
169
169
|
>
|
|
170
170
|
> Worktree 目录删除后,其对应的端口会在下一次触发 `wte` 时被回收。
|
|
171
|
+
>
|
|
172
|
+
> 旧版本生成的配置仍然兼容。旧配置项 `port_range`、`main_worktree`、`ports`、
|
|
173
|
+
> `secrets`、`writes`、`init` 和 `skip_if` 均作为上述短横线配置项的别名继续支持。
|
|
171
174
|
|
|
172
175
|
## Monitor 说明
|
|
173
176
|
|
|
@@ -188,9 +191,9 @@ wte monitor enable
|
|
|
188
191
|
Reconciler 会:
|
|
189
192
|
|
|
190
193
|
1. 执行 `git worktree list --porcelain` 获取真实 Worktree 列表。
|
|
191
|
-
2. 与 `ports.json` 对比,为尚未注册的 Worktree 分配端口、挂载 Secrets
|
|
194
|
+
2. 与 `ports.json` 对比,为尚未注册的 Worktree 分配端口、挂载 Secrets、生成文件并启动配置的初始化命令。
|
|
192
195
|
|
|
193
|
-
新增 Profile 或修改 `
|
|
196
|
+
新增 Profile 或修改 `main-worktree` 路径后,需要再次执行:
|
|
194
197
|
|
|
195
198
|
```bash
|
|
196
199
|
wte monitor enable
|
|
@@ -209,16 +212,16 @@ wte monitor disable
|
|
|
209
212
|
`wte` 可在 Worktree 创建后自动运行命令,可用于环境初始化:
|
|
210
213
|
|
|
211
214
|
```yaml
|
|
212
|
-
|
|
215
|
+
setup-commands:
|
|
213
216
|
- command: [npm]
|
|
214
217
|
args: [install]
|
|
215
218
|
cwd: frontend-dir # 若在 Worktree 根目录执行,则填写“.”
|
|
216
|
-
|
|
219
|
+
skip-if: node_modules # 若该文件或目录存在,则跳过该命令
|
|
217
220
|
|
|
218
221
|
- command: [uv]
|
|
219
222
|
args: [sync]
|
|
220
223
|
cwd: backend-dir # 若在 Worktree 根目录执行,则填写“.”
|
|
221
|
-
|
|
224
|
+
skip-if: .venv # 若该文件或目录存在,则跳过该命令
|
|
222
225
|
```
|
|
223
226
|
|
|
224
227
|
典型时间线如下:
|
|
@@ -230,7 +233,7 @@ init:
|
|
|
230
233
|
→ 用户或 AI 启动项目时,依赖通常已经准备完成
|
|
231
234
|
```
|
|
232
235
|
|
|
233
|
-
|
|
236
|
+
异步初始化由正常的 `post-checkout` Hook 启动;当 Monitor Reconciler 发现尚未注册的 Worktree 时也会启动。Reconciler 不会重复处理已注册的 Worktree,同时每条命令的 `skip-if` 标记可进一步避免重复初始化。`wte sync` 不会执行初始化命令。
|
|
234
237
|
|
|
235
238
|
## `wte` 支持的命令
|
|
236
239
|
|
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
name: example-fullstack
|
|
3
3
|
|
|
4
4
|
match:
|
|
5
|
-
|
|
5
|
+
main-worktree: $HOME/code/example-app
|
|
6
6
|
|
|
7
7
|
# Claims are allocated as one contiguous block in declaration order.
|
|
8
|
-
|
|
8
|
+
port-claims:
|
|
9
9
|
- id: frontend
|
|
10
10
|
- id: backend
|
|
11
11
|
|
|
12
12
|
# Sources stay outside Git. Targets are symlinks inside every linked worktree.
|
|
13
|
-
|
|
13
|
+
link-files:
|
|
14
14
|
- source: $HOME/.config/example-app/frontend.env
|
|
15
15
|
target: apps/frontend/.env
|
|
16
16
|
- source: $HOME/.config/example-app/backend.env
|
|
17
17
|
target: apps/backend/.env
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
write-files:
|
|
20
20
|
- path: apps/frontend/.env.development
|
|
21
21
|
body: |
|
|
22
22
|
VITE_PORT=${frontend}
|
|
@@ -29,12 +29,12 @@ writes:
|
|
|
29
29
|
|
|
30
30
|
# Initializers run in the background only from the post-checkout hook.
|
|
31
31
|
# Each command is trusted local configuration and is executed directly.
|
|
32
|
-
|
|
32
|
+
setup-commands:
|
|
33
33
|
- command: [npm]
|
|
34
34
|
args: [install]
|
|
35
35
|
cwd: apps/frontend
|
|
36
|
-
|
|
36
|
+
skip-if: node_modules
|
|
37
37
|
- command: [uv]
|
|
38
38
|
args: [sync]
|
|
39
39
|
cwd: apps/backend
|
|
40
|
-
|
|
40
|
+
skip-if: .venv
|
|
@@ -12,7 +12,7 @@ from . import __version__
|
|
|
12
12
|
from .config import initialize_config, initialize_profile_template, load_port_pool
|
|
13
13
|
from .hooks import hooks_status, install_hooks, uninstall_hooks
|
|
14
14
|
from .paths import AppPaths
|
|
15
|
-
from .profiles import load_profiles, validate_profiles, worktree_root
|
|
15
|
+
from .profiles import aliased_value, load_profiles, validate_profiles, worktree_root
|
|
16
16
|
from .projector import apply_worktree, print_apply_result
|
|
17
17
|
from .reconciler import (
|
|
18
18
|
install_monitor,
|
|
@@ -144,7 +144,7 @@ def _cmd_doctor(paths: AppPaths) -> int:
|
|
|
144
144
|
print(f"[wte] warning: reconciler is not active ({monitor.detail})")
|
|
145
145
|
|
|
146
146
|
for profile in load_profiles(paths, strict=False):
|
|
147
|
-
for entry in profile
|
|
147
|
+
for entry in aliased_value(profile, "link-files", "secrets", default=[]) or []:
|
|
148
148
|
if not isinstance(entry, dict) or not entry.get("source"):
|
|
149
149
|
continue
|
|
150
150
|
source = expand_profile_path(str(entry["source"]), profile).resolve()
|
|
@@ -14,7 +14,7 @@ from .utils import WteError
|
|
|
14
14
|
DEFAULT_POOL_START = 20000
|
|
15
15
|
DEFAULT_POOL_END = 29999
|
|
16
16
|
DEFAULT_PROFILE_TEMPLATE_NAME = "project.example.yaml.template"
|
|
17
|
-
DEFAULT_CONFIG = """# Inclusive range used for per-worktree port allocation.\
|
|
17
|
+
DEFAULT_CONFIG = """# Inclusive range used for per-worktree port allocation.\nport-range:\n start: 20000\n end: 29999\n"""
|
|
18
18
|
DEFAULT_PROFILE_TEMPLATE = """# Copy this file to a root-level *.yaml file, then edit every example value.
|
|
19
19
|
# Example: cp project.example.yaml.template my-project.yaml
|
|
20
20
|
# Run `wte monitor enable` after adding or changing profiles to refresh monitoring.
|
|
@@ -25,24 +25,24 @@ name: example-fullstack
|
|
|
25
25
|
match:
|
|
26
26
|
# Exact path of this project's dedicated main worktree. Linked worktrees
|
|
27
27
|
# created from it are matched automatically, regardless of their location.
|
|
28
|
-
|
|
28
|
+
main-worktree: $HOME/code/example-app
|
|
29
29
|
|
|
30
30
|
# Port IDs form one contiguous block in declaration order. Use each ID as a
|
|
31
|
-
# ${placeholder} in
|
|
32
|
-
|
|
31
|
+
# ${placeholder} in write-files below.
|
|
32
|
+
port-claims:
|
|
33
33
|
- id: frontend
|
|
34
34
|
- id: backend
|
|
35
35
|
|
|
36
36
|
# Optional local secret files. Sources stay outside Git; targets are replaced
|
|
37
37
|
# with symlinks inside each matched worktree.
|
|
38
|
-
|
|
38
|
+
link-files:
|
|
39
39
|
- source: $HOME/.config/example-app/frontend.env
|
|
40
40
|
target: apps/frontend/.env
|
|
41
41
|
- source: $HOME/.config/example-app/backend.env
|
|
42
42
|
target: apps/backend/.env
|
|
43
43
|
|
|
44
44
|
# Optional generated files. Each target is overwritten completely on sync.
|
|
45
|
-
|
|
45
|
+
write-files:
|
|
46
46
|
- path: apps/frontend/.env.development
|
|
47
47
|
body: |
|
|
48
48
|
VITE_PORT=${frontend}
|
|
@@ -53,18 +53,18 @@ writes:
|
|
|
53
53
|
PORT=${backend}
|
|
54
54
|
CORS_ORIGIN=http://127.0.0.1:${frontend}
|
|
55
55
|
|
|
56
|
-
# Optional post-checkout
|
|
57
|
-
# executed directly in the background.
|
|
58
|
-
# is resolved relative to cwd.
|
|
59
|
-
|
|
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.
|
|
59
|
+
setup-commands:
|
|
60
60
|
- command: [npm]
|
|
61
61
|
args: [install]
|
|
62
62
|
cwd: apps/frontend
|
|
63
|
-
|
|
63
|
+
skip-if: node_modules
|
|
64
64
|
- command: [uv]
|
|
65
65
|
args: [sync]
|
|
66
66
|
cwd: apps/backend
|
|
67
|
-
|
|
67
|
+
skip-if: .venv
|
|
68
68
|
"""
|
|
69
69
|
|
|
70
70
|
|
|
@@ -77,11 +77,11 @@ class PortPool:
|
|
|
77
77
|
|
|
78
78
|
def validate(self) -> None:
|
|
79
79
|
if not 1 <= self.start <= 65535:
|
|
80
|
-
raise WteError(f"
|
|
80
|
+
raise WteError(f"port-range.start is outside 1-65535: {self.start}")
|
|
81
81
|
if not 1 <= self.end <= 65535:
|
|
82
|
-
raise WteError(f"
|
|
82
|
+
raise WteError(f"port-range.end is outside 1-65535: {self.end}")
|
|
83
83
|
if self.end < self.start:
|
|
84
|
-
raise WteError("
|
|
84
|
+
raise WteError("port-range.end must be greater than or equal to port-range.start")
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def _load_yaml_mapping(path: Path) -> Dict[str, Any]:
|
|
@@ -101,16 +101,20 @@ def load_port_pool(paths: AppPaths) -> PortPool:
|
|
|
101
101
|
pool.validate()
|
|
102
102
|
return pool
|
|
103
103
|
data = _load_yaml_mapping(paths.config)
|
|
104
|
-
if "pool" in data and "port_range" not in data:
|
|
105
|
-
raise WteError("config key 'pool' was renamed to '
|
|
106
|
-
|
|
104
|
+
if "pool" in data and "port-range" not in data and "port_range" not in data:
|
|
105
|
+
raise WteError("config key 'pool' was renamed to 'port-range'")
|
|
106
|
+
if "port-range" in data:
|
|
107
|
+
raw_pool = data["port-range"]
|
|
108
|
+
else:
|
|
109
|
+
# ``port_range`` remains supported for configs created by older releases.
|
|
110
|
+
raw_pool = data.get("port_range") or {}
|
|
107
111
|
if not isinstance(raw_pool, dict):
|
|
108
|
-
raise WteError(f"
|
|
112
|
+
raise WteError(f"port-range must be a mapping: {paths.config}")
|
|
109
113
|
try:
|
|
110
114
|
start = int(raw_pool.get("start") or DEFAULT_POOL_START)
|
|
111
115
|
end = int(raw_pool.get("end") or DEFAULT_POOL_END)
|
|
112
116
|
except (TypeError, ValueError) as exc:
|
|
113
|
-
raise WteError("
|
|
117
|
+
raise WteError("port-range.start and port-range.end must be integers") from exc
|
|
114
118
|
pool = PortPool(start, end)
|
|
115
119
|
pool.validate()
|
|
116
120
|
return pool
|
|
@@ -17,6 +17,21 @@ Profile = Dict[str, Any]
|
|
|
17
17
|
PORT_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def aliased_value(
|
|
21
|
+
mapping: Dict[str, Any], canonical: str, *legacy: str, default: Any = None
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Read a canonical configuration key, falling back to legacy aliases."""
|
|
24
|
+
for key in (canonical, *legacy):
|
|
25
|
+
if key in mapping:
|
|
26
|
+
return mapping[key]
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _selected_key(mapping: Dict[str, Any], canonical: str, *legacy: str) -> str:
|
|
31
|
+
"""Return the key whose value :func:`aliased_value` will select."""
|
|
32
|
+
return next((key for key in (canonical, *legacy) if key in mapping), canonical)
|
|
33
|
+
|
|
34
|
+
|
|
20
35
|
def parse_profile(path: Path) -> Profile:
|
|
21
36
|
"""Parse one YAML or JSON profile and attach its source path."""
|
|
22
37
|
try:
|
|
@@ -96,7 +111,11 @@ def main_worktree_root(root: Path) -> Path:
|
|
|
96
111
|
def configured_main_worktree(profile: Profile) -> Optional[Path]:
|
|
97
112
|
"""Return the normalized main-worktree path configured by a profile."""
|
|
98
113
|
match = profile.get("match") or {}
|
|
99
|
-
raw =
|
|
114
|
+
raw = (
|
|
115
|
+
aliased_value(match, "main-worktree", "main_worktree")
|
|
116
|
+
if isinstance(match, dict)
|
|
117
|
+
else None
|
|
118
|
+
)
|
|
100
119
|
if not raw:
|
|
101
120
|
return None
|
|
102
121
|
return expand_home(str(raw)).resolve()
|
|
@@ -118,8 +137,15 @@ def find_profile(paths: AppPaths, root: Path) -> Optional[Profile]:
|
|
|
118
137
|
return hits[0]
|
|
119
138
|
|
|
120
139
|
|
|
140
|
+
def port_claims_value(profile: Profile) -> Any:
|
|
141
|
+
"""Read port claims while preserving the older ``ports``/``services`` fallback."""
|
|
142
|
+
if "port-claims" in profile:
|
|
143
|
+
return profile["port-claims"]
|
|
144
|
+
return profile.get("ports") or profile.get("services") or []
|
|
145
|
+
|
|
146
|
+
|
|
121
147
|
def _port_claims(profile: Profile) -> Sequence[Dict[str, Any]]:
|
|
122
|
-
raw =
|
|
148
|
+
raw = port_claims_value(profile)
|
|
123
149
|
return raw if isinstance(raw, list) else []
|
|
124
150
|
|
|
125
151
|
|
|
@@ -146,12 +172,19 @@ def validate_profiles(paths: AppPaths) -> Tuple[List[str], List[str]]:
|
|
|
146
172
|
names[name] = source
|
|
147
173
|
|
|
148
174
|
match = profile.get("match") or {}
|
|
149
|
-
|
|
175
|
+
if isinstance(match, dict):
|
|
176
|
+
main_key = _selected_key(match, "main-worktree", "main_worktree")
|
|
177
|
+
main_raw = aliased_value(match, "main-worktree", "main_worktree")
|
|
178
|
+
else:
|
|
179
|
+
main_key = "main-worktree"
|
|
180
|
+
main_raw = None
|
|
150
181
|
main_root = configured_main_worktree(profile)
|
|
151
182
|
if main_root is None:
|
|
152
|
-
errors.append(f"{label}: match.
|
|
183
|
+
errors.append(f"{label}: match.{main_key} is required")
|
|
153
184
|
elif not expand_home(str(main_raw)).is_absolute():
|
|
154
|
-
errors.append(
|
|
185
|
+
errors.append(
|
|
186
|
+
f"{label}: match.{main_key} must be an absolute or home-relative path"
|
|
187
|
+
)
|
|
155
188
|
elif main_root in main_roots:
|
|
156
189
|
errors.append(
|
|
157
190
|
f"{label}: main worktree is also configured by {main_roots[main_root].name}"
|
|
@@ -161,64 +194,85 @@ def validate_profiles(paths: AppPaths) -> Tuple[List[str], List[str]]:
|
|
|
161
194
|
if not main_root.exists():
|
|
162
195
|
warnings.append(f"{label}: main worktree does not exist: {main_root}")
|
|
163
196
|
|
|
197
|
+
if "port-claims" in profile:
|
|
198
|
+
claims_key = "port-claims"
|
|
199
|
+
elif profile.get("ports") or "services" not in profile:
|
|
200
|
+
claims_key = "ports"
|
|
201
|
+
else:
|
|
202
|
+
claims_key = "services"
|
|
164
203
|
claims = _port_claims(profile)
|
|
165
204
|
if not claims:
|
|
166
|
-
errors.append(f"{label}:
|
|
205
|
+
errors.append(f"{label}: {claims_key} must contain at least one claim")
|
|
167
206
|
continue
|
|
168
207
|
ids: List[str] = []
|
|
169
208
|
for index, claim in enumerate(claims):
|
|
170
209
|
port_id = claim.get("id") if isinstance(claim, dict) else None
|
|
171
210
|
if not isinstance(port_id, str) or not PORT_ID.match(port_id):
|
|
172
|
-
errors.append(f"{label}:
|
|
211
|
+
errors.append(f"{label}: {claims_key}[{index}].id is invalid")
|
|
173
212
|
elif port_id in ids:
|
|
174
213
|
errors.append(f"{label}: duplicate port id {port_id!r}")
|
|
175
214
|
else:
|
|
176
215
|
ids.append(port_id)
|
|
177
216
|
|
|
178
217
|
mapping = {port_id: "0" for port_id in ids}
|
|
179
|
-
|
|
218
|
+
writes_key = _selected_key(profile, "write-files", "writes")
|
|
219
|
+
writes = aliased_value(profile, "write-files", "writes", default=[])
|
|
220
|
+
if writes is None:
|
|
221
|
+
writes = []
|
|
180
222
|
if not isinstance(writes, list):
|
|
181
|
-
errors.append(f"{label}:
|
|
223
|
+
errors.append(f"{label}: {writes_key} must be a list")
|
|
182
224
|
continue
|
|
183
225
|
for index, spec in enumerate(writes):
|
|
184
226
|
if not isinstance(spec, dict):
|
|
185
|
-
errors.append(f"{label}:
|
|
227
|
+
errors.append(f"{label}: {writes_key}[{index}] must be a mapping")
|
|
186
228
|
continue
|
|
187
229
|
write_path = spec.get("path")
|
|
188
230
|
if not write_path:
|
|
189
|
-
errors.append(f"{label}:
|
|
231
|
+
errors.append(f"{label}: {writes_key}[{index}].path is required")
|
|
190
232
|
elif Path(str(write_path)).is_absolute() or ".." in Path(str(write_path)).parts:
|
|
191
|
-
errors.append(
|
|
233
|
+
errors.append(
|
|
234
|
+
f"{label}: {writes_key}[{index}].path must stay inside the worktree"
|
|
235
|
+
)
|
|
192
236
|
try:
|
|
193
237
|
Template(str(spec.get("body") or "")).substitute(mapping)
|
|
194
238
|
except (KeyError, ValueError) as exc:
|
|
195
|
-
errors.append(
|
|
239
|
+
errors.append(
|
|
240
|
+
f"{label}: {writes_key}[{index}].body has an invalid variable: {exc}"
|
|
241
|
+
)
|
|
196
242
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
243
|
+
links_key = _selected_key(profile, "link-files", "secrets")
|
|
244
|
+
links = aliased_value(profile, "link-files", "secrets", default=[])
|
|
245
|
+
if links is None:
|
|
246
|
+
links = []
|
|
247
|
+
if not isinstance(links, list):
|
|
248
|
+
errors.append(f"{label}: {links_key} must be a list")
|
|
200
249
|
else:
|
|
201
|
-
for index, entry in enumerate(
|
|
250
|
+
for index, entry in enumerate(links):
|
|
202
251
|
if not isinstance(entry, dict):
|
|
203
|
-
errors.append(f"{label}:
|
|
252
|
+
errors.append(f"{label}: {links_key}[{index}] must be a mapping")
|
|
204
253
|
continue
|
|
205
254
|
if not entry.get("source"):
|
|
206
|
-
errors.append(f"{label}:
|
|
255
|
+
errors.append(f"{label}: {links_key}[{index}].source is required")
|
|
207
256
|
target = entry.get("target")
|
|
208
257
|
if not target:
|
|
209
|
-
errors.append(f"{label}:
|
|
258
|
+
errors.append(f"{label}: {links_key}[{index}].target is required")
|
|
210
259
|
elif Path(str(target)).is_absolute() or ".." in Path(str(target)).parts:
|
|
211
|
-
errors.append(
|
|
260
|
+
errors.append(
|
|
261
|
+
f"{label}: {links_key}[{index}].target must stay inside the worktree"
|
|
262
|
+
)
|
|
212
263
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
264
|
+
setup_key = _selected_key(profile, "setup-commands", "init")
|
|
265
|
+
setup_commands = aliased_value(
|
|
266
|
+
profile, "setup-commands", "init", default=[]
|
|
267
|
+
)
|
|
268
|
+
if setup_commands is None:
|
|
269
|
+
setup_commands = []
|
|
270
|
+
if not isinstance(setup_commands, list):
|
|
271
|
+
errors.append(f"{label}: {setup_key} must be a list")
|
|
218
272
|
else:
|
|
219
|
-
for index, entry in enumerate(
|
|
273
|
+
for index, entry in enumerate(setup_commands):
|
|
220
274
|
if not isinstance(entry, dict):
|
|
221
|
-
errors.append(f"{label}:
|
|
275
|
+
errors.append(f"{label}: {setup_key}[{index}] must be a mapping")
|
|
222
276
|
continue
|
|
223
277
|
command = entry.get("command")
|
|
224
278
|
if (
|
|
@@ -228,16 +282,18 @@ def validate_profiles(paths: AppPaths) -> Tuple[List[str], List[str]]:
|
|
|
228
282
|
or not command[0].strip()
|
|
229
283
|
):
|
|
230
284
|
errors.append(
|
|
231
|
-
f"{label}:
|
|
285
|
+
f"{label}: {setup_key}[{index}].command must be a non-empty string list"
|
|
232
286
|
)
|
|
233
287
|
args = entry.get("args", [])
|
|
234
288
|
if not isinstance(args, list) or not all(
|
|
235
289
|
isinstance(value, str) for value in args
|
|
236
290
|
):
|
|
237
|
-
errors.append(
|
|
291
|
+
errors.append(
|
|
292
|
+
f"{label}: {setup_key}[{index}].args must be a string list"
|
|
293
|
+
)
|
|
238
294
|
cwd = entry.get("cwd")
|
|
239
295
|
if not isinstance(cwd, str) or not cwd.strip():
|
|
240
|
-
errors.append(f"{label}:
|
|
296
|
+
errors.append(f"{label}: {setup_key}[{index}].cwd is required")
|
|
241
297
|
|
|
242
298
|
if not profiles:
|
|
243
299
|
warnings.append(f"no profiles found in {paths.profiles}")
|
|
@@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional
|
|
|
13
13
|
|
|
14
14
|
from .config import load_port_pool
|
|
15
15
|
from .paths import AppPaths
|
|
16
|
-
from .profiles import Profile, find_profile
|
|
16
|
+
from .profiles import Profile, aliased_value, find_profile
|
|
17
17
|
from .registry import (
|
|
18
18
|
allocate_ports,
|
|
19
19
|
load_registry,
|
|
@@ -41,7 +41,7 @@ def render_template(body: str, ports: Dict[str, int]) -> str:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _secret_entries(profile: Profile) -> List[Dict[str, Any]]:
|
|
44
|
-
raw = profile
|
|
44
|
+
raw = aliased_value(profile, "link-files", "secrets", default=[]) or []
|
|
45
45
|
return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
|
|
46
46
|
|
|
47
47
|
|
|
@@ -67,9 +67,9 @@ def apply_secrets(profile: Profile, root: Path) -> None:
|
|
|
67
67
|
|
|
68
68
|
def apply_writes(profile: Profile, root: Path, ports: Dict[str, int]) -> None:
|
|
69
69
|
"""Render complete generated files declared by the profile."""
|
|
70
|
-
writes = profile
|
|
70
|
+
writes = aliased_value(profile, "write-files", "writes", default=[]) or []
|
|
71
71
|
if not isinstance(writes, list):
|
|
72
|
-
raise WteError(f"profile {profile.get('name')} has an invalid
|
|
72
|
+
raise WteError(f"profile {profile.get('name')} has an invalid write-files section")
|
|
73
73
|
for spec in writes:
|
|
74
74
|
if not isinstance(spec, dict) or not spec.get("path"):
|
|
75
75
|
raise WteError(f"profile {profile.get('name')} has an invalid write entry")
|
|
@@ -84,7 +84,7 @@ def apply_writes(profile: Profile, root: Path, ports: Dict[str, int]) -> None:
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def _init_entries(profile: Profile) -> List[Dict[str, Any]]:
|
|
87
|
-
raw = profile
|
|
87
|
+
raw = aliased_value(profile, "setup-commands", "init", default=[]) or []
|
|
88
88
|
return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
|
|
89
89
|
|
|
90
90
|
|
|
@@ -115,7 +115,7 @@ def run_initializers(profile: Profile, root: Path) -> None:
|
|
|
115
115
|
if not cwd.is_dir():
|
|
116
116
|
log(f"initializer skipped; directory does not exist: {cwd}")
|
|
117
117
|
continue
|
|
118
|
-
skip_if = item
|
|
118
|
+
skip_if = aliased_value(item, "skip-if", "skip_if")
|
|
119
119
|
if skip_if:
|
|
120
120
|
marker = expand_profile_path(str(skip_if), profile, root=cwd)
|
|
121
121
|
if marker.exists():
|
|
@@ -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)
|
|
@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterator, Set, Tuple
|
|
|
13
13
|
|
|
14
14
|
from .config import PortPool
|
|
15
15
|
from .paths import AppPaths
|
|
16
|
-
from .profiles import Profile
|
|
16
|
+
from .profiles import Profile, port_claims_value
|
|
17
17
|
from .utils import WteError
|
|
18
18
|
|
|
19
19
|
Registry = Dict[str, Any]
|
|
@@ -126,7 +126,7 @@ def allocate_ports(
|
|
|
126
126
|
pool: PortPool,
|
|
127
127
|
) -> Tuple[int, Dict[str, int]]:
|
|
128
128
|
"""Reuse a valid sticky allocation or claim the first free contiguous block."""
|
|
129
|
-
claims =
|
|
129
|
+
claims = port_claims_value(profile)
|
|
130
130
|
if not isinstance(claims, list) or not claims:
|
|
131
131
|
raise WteError(f"profile {profile.get('name')} has no ports to claim")
|
|
132
132
|
try:
|
|
@@ -16,10 +16,11 @@ def test_init_generates_documented_port_range(tmp_path, monkeypatch):
|
|
|
16
16
|
assert initialize_config(paths) is True
|
|
17
17
|
assert paths.config.read_text() == (
|
|
18
18
|
"# Inclusive range used for per-worktree port allocation.\n"
|
|
19
|
-
"
|
|
19
|
+
"port-range:\n"
|
|
20
20
|
" start: 20000\n"
|
|
21
21
|
" end: 29999\n"
|
|
22
22
|
)
|
|
23
|
+
assert load_port_pool(paths).start == 20000
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def test_init_template_is_commented_and_not_an_active_yaml_profile(app_paths):
|
|
@@ -32,7 +33,13 @@ def test_init_template_is_commented_and_not_an_active_yaml_profile(app_paths):
|
|
|
32
33
|
assert "# Port IDs form one contiguous block" in text
|
|
33
34
|
assert "# Optional local secret files" in text
|
|
34
35
|
assert "# Optional generated files" in text
|
|
35
|
-
assert "# Optional
|
|
36
|
+
assert "# Optional worktree initializers" in text
|
|
37
|
+
assert "main-worktree:" in text
|
|
38
|
+
assert "port-claims:" in text
|
|
39
|
+
assert "link-files:" in text
|
|
40
|
+
assert "write-files:" in text
|
|
41
|
+
assert "setup-commands:" in text
|
|
42
|
+
assert "skip-if:" in text
|
|
36
43
|
assert "$HOME/.config/example-app/frontend.env" in text
|
|
37
44
|
assert "$HOME/.config/example-app/backend.env" in text
|
|
38
45
|
assert "cwd: apps/frontend" in text
|
|
@@ -40,8 +47,16 @@ def test_init_template_is_commented_and_not_an_active_yaml_profile(app_paths):
|
|
|
40
47
|
assert "wte monitor enable" in text
|
|
41
48
|
|
|
42
49
|
|
|
50
|
+
def test_legacy_port_range_key_remains_supported(app_paths):
|
|
51
|
+
app_paths.config.write_text("port_range:\n start: 41000\n end: 41099\n")
|
|
52
|
+
|
|
53
|
+
pool = load_port_pool(app_paths)
|
|
54
|
+
|
|
55
|
+
assert (pool.start, pool.end) == (41000, 41099)
|
|
56
|
+
|
|
57
|
+
|
|
43
58
|
def test_legacy_pool_key_is_rejected(app_paths):
|
|
44
59
|
app_paths.config.write_text("pool:\n start: 41000\n end: 41099\n")
|
|
45
60
|
|
|
46
|
-
with pytest.raises(WteError, match="renamed to '
|
|
61
|
+
with pytest.raises(WteError, match="renamed to 'port-range'"):
|
|
47
62
|
load_port_pool(app_paths)
|
|
@@ -34,12 +34,12 @@ def test_validation_rejects_duplicate_main_worktrees(app_paths, git_worktrees):
|
|
|
34
34
|
assert any("also configured" in error for error in errors)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def test_validation_accepts_setup_command_and_args_lists(app_paths, tmp_path):
|
|
38
38
|
(app_paths.profiles / "example.yaml").write_text(
|
|
39
39
|
"name: example\n"
|
|
40
|
-
f"match:\n
|
|
41
|
-
"
|
|
42
|
-
"
|
|
40
|
+
f"match:\n main-worktree: {tmp_path}\n"
|
|
41
|
+
"port-claims:\n - id: web\n"
|
|
42
|
+
"setup-commands:\n"
|
|
43
43
|
" - command: [npm]\n"
|
|
44
44
|
" args: [install]\n"
|
|
45
45
|
" cwd: .\n"
|
|
@@ -5,18 +5,34 @@ from worktree_env.registry import load_registry
|
|
|
5
5
|
from worktree_env.utils import WteError
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
("main_key", "claims_key", "links_key", "writes_key"),
|
|
10
|
+
[
|
|
11
|
+
("main-worktree", "port-claims", "link-files", "write-files"),
|
|
12
|
+
("main_worktree", "ports", "secrets", "writes"),
|
|
13
|
+
],
|
|
14
|
+
)
|
|
15
|
+
def test_projection_links_secrets_and_writes_ports(
|
|
16
|
+
app_paths,
|
|
17
|
+
git_worktrees,
|
|
18
|
+
tmp_path,
|
|
19
|
+
monkeypatch,
|
|
20
|
+
main_key,
|
|
21
|
+
claims_key,
|
|
22
|
+
links_key,
|
|
23
|
+
writes_key,
|
|
24
|
+
):
|
|
9
25
|
main, linked = git_worktrees
|
|
10
26
|
secret = tmp_path / "backend.env"
|
|
11
27
|
secret.write_text("TOKEN=test\n")
|
|
12
28
|
(app_paths.profiles / "example.yaml").write_text(
|
|
13
29
|
"name: example\n"
|
|
14
|
-
f"match:\n
|
|
15
|
-
"
|
|
16
|
-
"
|
|
30
|
+
f"match:\n {main_key}: {main}\n"
|
|
31
|
+
f"{claims_key}:\n - id: frontend\n - id: backend\n"
|
|
32
|
+
f"{links_key}:\n"
|
|
17
33
|
f" - source: {secret}\n"
|
|
18
34
|
" target: app/.env\n"
|
|
19
|
-
"
|
|
35
|
+
f"{writes_key}:\n"
|
|
20
36
|
" - path: app/.env.development\n"
|
|
21
37
|
" body: |\n"
|
|
22
38
|
" PORT=${frontend}\n"
|
|
@@ -39,17 +55,22 @@ def test_projection_links_secrets_and_writes_ports(app_paths, git_worktrees, tmp
|
|
|
39
55
|
assert load_registry(app_paths)[str(linked)]["profile"] == "example"
|
|
40
56
|
|
|
41
57
|
|
|
58
|
+
@pytest.mark.parametrize(
|
|
59
|
+
("setup_key", "skip_key"),
|
|
60
|
+
[("setup-commands", "skip-if"), ("init", "skip_if")],
|
|
61
|
+
)
|
|
42
62
|
def test_initializers_execute_command_and_args_without_a_shell(
|
|
43
|
-
tmp_path, monkeypatch, capsys
|
|
63
|
+
tmp_path, monkeypatch, capsys, setup_key, skip_key
|
|
44
64
|
):
|
|
45
65
|
calls = []
|
|
46
66
|
profile = {
|
|
47
67
|
"name": "example",
|
|
48
|
-
|
|
68
|
+
setup_key: [
|
|
49
69
|
{
|
|
50
70
|
"command": ["tool", "subcommand"],
|
|
51
71
|
"args": ["literal;value", "two words"],
|
|
52
72
|
"cwd": ".",
|
|
73
|
+
skip_key: "missing-marker",
|
|
53
74
|
}
|
|
54
75
|
],
|
|
55
76
|
}
|
|
@@ -55,6 +55,30 @@ 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
|
):
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|