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.
Files changed (32) hide show
  1. {worktree_env-0.3.0 → worktree_env-0.4.0}/CHANGELOG.md +10 -0
  2. {worktree_env-0.3.0 → worktree_env-0.4.0}/PKG-INFO +20 -16
  3. {worktree_env-0.3.0 → worktree_env-0.4.0}/README.md +19 -15
  4. {worktree_env-0.3.0 → worktree_env-0.4.0}/README.zh-CN.md +14 -11
  5. {worktree_env-0.3.0 → worktree_env-0.4.0}/examples/fullstack.yaml +7 -7
  6. {worktree_env-0.3.0 → worktree_env-0.4.0}/pyproject.toml +1 -1
  7. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/__init__.py +1 -1
  8. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/cli.py +2 -2
  9. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/config.py +24 -20
  10. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/profiles.py +87 -31
  11. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/projector.py +6 -6
  12. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/reconciler.py +4 -1
  13. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/registry.py +2 -2
  14. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_config.py +18 -3
  15. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_profiles.py +4 -4
  16. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_projector.py +28 -7
  17. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_reconciler.py +24 -0
  18. {worktree_env-0.3.0 → worktree_env-0.4.0}/uv.lock +1 -1
  19. {worktree_env-0.3.0 → worktree_env-0.4.0}/.github/workflows/publish.yml +0 -0
  20. {worktree_env-0.3.0 → worktree_env-0.4.0}/.github/workflows/tests.yml +0 -0
  21. {worktree_env-0.3.0 → worktree_env-0.4.0}/.gitignore +0 -0
  22. {worktree_env-0.3.0 → worktree_env-0.4.0}/CONTRIBUTING.md +0 -0
  23. {worktree_env-0.3.0 → worktree_env-0.4.0}/LICENSE +0 -0
  24. {worktree_env-0.3.0 → worktree_env-0.4.0}/SECURITY.md +0 -0
  25. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/__main__.py +0 -0
  26. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/hooks.py +0 -0
  27. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/paths.py +0 -0
  28. {worktree_env-0.3.0 → worktree_env-0.4.0}/src/worktree_env/utils.py +0 -0
  29. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/conftest.py +0 -0
  30. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_cli.py +0 -0
  31. {worktree_env-0.3.0 → worktree_env-0.4.0}/tests/test_hooks.py +0 -0
  32. {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.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
- port_range:
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
- main_worktree: $HOME/code/example-app
207
+ main-worktree: $HOME/code/example-app
208
208
 
209
- ports:
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
- secrets:
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
- writes:
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, and generates
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 `main_worktree` path, run this command again:
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
- init:
292
+ setup-commands:
289
293
  - command: [npm]
290
294
  args: [install]
291
295
  cwd: frontend-dir # Use "." to run from the worktree root.
292
- skip_if: node_modules # Skip this command if the file or directory exists.
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
- skip_if: .venv # Skip this command if the file or directory exists.
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 only by the normal `post-checkout` hook.
310
- Neither `wte sync` nor the Monitor Reconciler runs these commands, preventing manual
311
- synchronization or background reconciliation from repeatedly starting expensive
312
- tasks.
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
- port_range:
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
- main_worktree: $HOME/code/example-app
180
+ main-worktree: $HOME/code/example-app
181
181
 
182
- ports:
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
- secrets:
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
- writes:
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, and generates
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 `main_worktree` path, run this command again:
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
- init:
265
+ setup-commands:
262
266
  - command: [npm]
263
267
  args: [install]
264
268
  cwd: frontend-dir # Use "." to run from the worktree root.
265
- skip_if: node_modules # Skip this command if the file or directory exists.
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
- skip_if: .venv # Skip this command if the file or directory exists.
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 only by the normal `post-checkout` hook.
283
- Neither `wte sync` nor the Monitor Reconciler runs these commands, preventing manual
284
- synchronization or background reconciliation from repeatedly starting expensive
285
- tasks.
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
- port_range:
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
- main_worktree: $HOME/code/example-app
140
+ main-worktree: $HOME/code/example-app
141
141
 
142
- ports:
142
+ port-claims:
143
143
  # 需要申请的端口的名称,项目需要多少就申请多少,id 在同一个 profile 中应保持唯一
144
144
  - id: frontend_port
145
145
  - id: backend_port
146
146
 
147
- secrets:
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
- writes:
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 或修改 `main_worktree` 路径后,需要再次执行:
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
- init:
215
+ setup-commands:
213
216
  - command: [npm]
214
217
  args: [install]
215
218
  cwd: frontend-dir # 若在 Worktree 根目录执行,则填写“.”
216
- skip_if: node_modules # 若该文件或目录存在,则跳过该命令
219
+ skip-if: node_modules # 若该文件或目录存在,则跳过该命令
217
220
 
218
221
  - command: [uv]
219
222
  args: [sync]
220
223
  cwd: backend-dir # 若在 Worktree 根目录执行,则填写“.”
221
- skip_if: .venv # 若该文件或目录存在,则跳过该命令
224
+ skip-if: .venv # 若该文件或目录存在,则跳过该命令
222
225
  ```
223
226
 
224
227
  典型时间线如下:
@@ -230,7 +233,7 @@ init:
230
233
  → 用户或 AI 启动项目时,依赖通常已经准备完成
231
234
  ```
232
235
 
233
- 异步初始化只由正常的 `post-checkout` Hook 启动。`wte sync` 和 Monitor Reconciler 都不会执行这些命令,避免手动同步或后台补偿重复启动耗时任务。
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
- main_worktree: $HOME/code/example-app
5
+ main-worktree: $HOME/code/example-app
6
6
 
7
7
  # Claims are allocated as one contiguous block in declaration order.
8
- ports:
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
- secrets:
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
- writes:
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
- init:
32
+ setup-commands:
33
33
  - command: [npm]
34
34
  args: [install]
35
35
  cwd: apps/frontend
36
- skip_if: node_modules
36
+ skip-if: node_modules
37
37
  - command: [uv]
38
38
  args: [sync]
39
39
  cwd: apps/backend
40
- skip_if: .venv
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.3.0"
7
+ version = "0.4.0"
8
8
  description = "Per-worktree ports, secrets, and local environment setup for Git"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """worktree-env public package."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -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.get("secrets") or []:
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.\nport_range:\n start: 20000\n end: 29999\n"""
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
- main_worktree: $HOME/code/example-app
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 writes below.
32
- ports:
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
- secrets:
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
- writes:
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 initializers. Commands are trusted local configuration
57
- # executed directly in the background. args are appended to command, and skip_if
58
- # is resolved relative to cwd.
59
- init:
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
- skip_if: node_modules
63
+ skip-if: node_modules
64
64
  - command: [uv]
65
65
  args: [sync]
66
66
  cwd: apps/backend
67
- skip_if: .venv
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"port_range.start is outside 1-65535: {self.start}")
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"port_range.end is outside 1-65535: {self.end}")
82
+ raise WteError(f"port-range.end is outside 1-65535: {self.end}")
83
83
  if self.end < self.start:
84
- raise WteError("port_range.end must be greater than or equal to port_range.start")
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 'port_range'")
106
- raw_pool = data.get("port_range") or {}
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"port_range must be a mapping: {paths.config}")
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("port_range.start and port_range.end must be integers") from exc
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 = match.get("main_worktree") if isinstance(match, dict) else None
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 = profile.get("ports") or profile.get("services") or []
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
- main_raw = match.get("main_worktree") if isinstance(match, dict) else None
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.main_worktree is required")
183
+ errors.append(f"{label}: match.{main_key} is required")
153
184
  elif not expand_home(str(main_raw)).is_absolute():
154
- errors.append(f"{label}: match.main_worktree must be an absolute or home-relative path")
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}: ports must contain at least one claim")
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}: ports[{index}].id is invalid")
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
- writes = profile.get("writes") or []
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}: writes must be a list")
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}: writes[{index}] must be a mapping")
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}: writes[{index}].path is required")
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(f"{label}: writes[{index}].path must stay inside the worktree")
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(f"{label}: writes[{index}].body has an invalid variable: {exc}")
239
+ errors.append(
240
+ f"{label}: {writes_key}[{index}].body has an invalid variable: {exc}"
241
+ )
196
242
 
197
- secrets = profile.get("secrets") or []
198
- if not isinstance(secrets, list):
199
- errors.append(f"{label}: secrets must be a list")
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(secrets):
250
+ for index, entry in enumerate(links):
202
251
  if not isinstance(entry, dict):
203
- errors.append(f"{label}: secrets[{index}] must be a mapping")
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}: secrets[{index}].source is required")
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}: secrets[{index}].target is required")
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(f"{label}: secrets[{index}].target must stay inside the worktree")
260
+ errors.append(
261
+ f"{label}: {links_key}[{index}].target must stay inside the worktree"
262
+ )
212
263
 
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")
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(initializers):
273
+ for index, entry in enumerate(setup_commands):
220
274
  if not isinstance(entry, dict):
221
- errors.append(f"{label}: init[{index}] must be a mapping")
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}: init[{index}].command must be a non-empty string list"
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(f"{label}: init[{index}].args must be a string list")
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}: init[{index}].cwd is required")
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.get("secrets") or []
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.get("writes") or []
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 writes section")
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.get("init") or []
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.get("skip_if")
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
- result = apply_worktree(paths, candidate, setup=False)
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 = profile.get("ports") or profile.get("services") or []
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
- "port_range:\n"
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 post-checkout initializers" in text
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 'port_range'"):
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 test_validation_accepts_initializer_command_and_args_lists(app_paths, tmp_path):
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 main_worktree: {tmp_path}\n"
41
- "ports:\n - id: web\n"
42
- "init:\n"
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
- def test_projection_links_secrets_and_writes_ports(app_paths, git_worktrees, tmp_path, monkeypatch):
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 main_worktree: {main}\n"
15
- "ports:\n - id: frontend\n - id: backend\n"
16
- "secrets:\n"
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
- "writes:\n"
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
- "init": [
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
  ):
@@ -258,7 +258,7 @@ wheels = [
258
258
 
259
259
  [[package]]
260
260
  name = "worktree-env"
261
- version = "0.3.0"
261
+ version = "0.4.0"
262
262
  source = { editable = "." }
263
263
  dependencies = [
264
264
  { name = "pyyaml" },
File without changes
File without changes
File without changes