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.
Files changed (36) hide show
  1. {worktree_env-0.2.0 → worktree_env-0.3.1}/.github/workflows/publish.yml +0 -6
  2. {worktree_env-0.2.0 → worktree_env-0.3.1}/CHANGELOG.md +12 -2
  3. {worktree_env-0.2.0 → worktree_env-0.3.1}/PKG-INFO +11 -9
  4. {worktree_env-0.2.0 → worktree_env-0.3.1}/README.md +10 -8
  5. {worktree_env-0.2.0 → worktree_env-0.3.1}/README.zh-CN.md +6 -4
  6. {worktree_env-0.2.0 → worktree_env-0.3.1}/examples/fullstack.yaml +10 -3
  7. {worktree_env-0.2.0 → worktree_env-0.3.1}/pyproject.toml +4 -4
  8. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/__init__.py +1 -1
  9. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/__main__.py +1 -1
  10. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/config.py +12 -4
  11. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/profiles.py +29 -0
  12. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/projector.py +22 -6
  13. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/reconciler.py +4 -1
  14. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/conftest.py +1 -1
  15. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_cli.py +2 -2
  16. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_config.py +8 -4
  17. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_hooks.py +3 -3
  18. worktree_env-0.3.1/tests/test_profiles.py +67 -0
  19. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_projector.py +35 -6
  20. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_reconciler.py +29 -5
  21. {worktree_env-0.2.0 → worktree_env-0.3.1}/tests/test_registry.py +4 -4
  22. {worktree_env-0.2.0 → worktree_env-0.3.1}/uv.lock +1 -1
  23. worktree_env-0.2.0/migration/git-worktree-env/README.md +0 -43
  24. worktree_env-0.2.0/migration/git-worktree-env/pyproject.toml +0 -37
  25. worktree_env-0.2.0/migration/git-worktree-env/src/git_worktree_env_compat/__init__.py +0 -8
  26. worktree_env-0.2.0/tests/test_profiles.py +0 -34
  27. {worktree_env-0.2.0 → worktree_env-0.3.1}/.github/workflows/tests.yml +0 -0
  28. {worktree_env-0.2.0 → worktree_env-0.3.1}/.gitignore +0 -0
  29. {worktree_env-0.2.0 → worktree_env-0.3.1}/CONTRIBUTING.md +0 -0
  30. {worktree_env-0.2.0 → worktree_env-0.3.1}/LICENSE +0 -0
  31. {worktree_env-0.2.0 → worktree_env-0.3.1}/SECURITY.md +0 -0
  32. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/cli.py +0 -0
  33. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/hooks.py +0 -0
  34. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/paths.py +0 -0
  35. {worktree_env-0.2.0/src/git_worktree_env → worktree_env-0.3.1/src/worktree_env}/registry.py +0 -0
  36. {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, `git_worktree_env` Python module, and existing
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.2.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, and generates
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 install
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 sync
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 only by the normal `post-checkout` hook.
308
- Neither `wte sync` nor the Monitor Reconciler runs these commands, preventing manual
309
- synchronization or background reconciliation from repeatedly starting expensive
310
- tasks.
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, and generates
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 install
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 sync
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 only by the normal `post-checkout` hook.
281
- Neither `wte sync` nor the Monitor Reconciler runs these commands, preventing manual
282
- synchronization or background reconciliation from repeatedly starting expensive
283
- tasks.
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 install
213
+ - command: [npm]
214
+ args: [install]
214
215
  cwd: frontend-dir # 若在 Worktree 根目录执行,则填写“.”
215
216
  skip_if: node_modules # 若该文件或目录存在,则跳过该命令
216
217
 
217
- - command: uv sync
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
- 异步初始化只由正常的 `post-checkout` Hook 启动。`wte sync` 和 Monitor Reconciler 都不会执行这些命令,避免手动同步或后台补偿重复启动耗时任务。
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 by Bash.
31
+ # Each command is trusted local configuration and is executed directly.
30
32
  init:
31
- - command: npm install
32
- cwd: .
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.2.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 = "git_worktree_env.cli:main"
41
- worktree-env = "git_worktree_env.cli:main"
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/git_worktree_env"]
49
+ packages = ["src/worktree_env"]
50
50
 
51
51
  [tool.pytest.ini_options]
52
52
  testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """worktree-env public package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -1,4 +1,4 @@
1
- """Allow ``python -m git_worktree_env`` to behave like ``wte``."""
1
+ """Allow ``python -m worktree_env`` to behave like ``wte``."""
2
2
 
3
3
  from .cli import main
4
4
 
@@ -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 initializers. Commands are trusted local configuration
55
- # executed by Bash in the background. skip_if is resolved relative to cwd.
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 install
58
- cwd: .
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
@@ -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 = str(item.get("command") or "").strip()
94
- cwd_raw = str(item.get("cwd") or "").strip()
95
- if not command or not cwd_raw:
96
- log(f"initializer skipped; command and cwd are required: {item}")
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
- ["bash", "-lc", command],
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: {command} -> {log_path}")
135
+ log(f"initializer started: {shlex.join(argv)} -> {log_path}")
120
136
 
121
137
 
122
138
  def apply_worktree(
@@ -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)
@@ -6,7 +6,7 @@ from typing import Sequence
6
6
 
7
7
  import pytest
8
8
 
9
- from git_worktree_env.paths import AppPaths
9
+ from worktree_env.paths import AppPaths
10
10
 
11
11
 
12
12
  def run(command: Sequence[str], cwd: Path) -> str:
@@ -2,7 +2,7 @@ import argparse
2
2
 
3
3
  import pytest
4
4
 
5
- from git_worktree_env.cli import _build_parser, _warn_if_legacy_distribution_installed
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
- "git_worktree_env.cli.metadata.distribution", lambda _name: object()
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 git_worktree_env.config import (
3
+ from worktree_env.config import (
4
4
  initialize_config,
5
5
  initialize_profile_template,
6
6
  load_port_pool,
7
7
  )
8
- from git_worktree_env.paths import AppPaths
9
- from git_worktree_env.utils import WteError
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 post-checkout initializers" in text
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 git_worktree_env.hooks import _dispatcher_script, install_hooks
6
- from git_worktree_env.utils import WteError
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
- "git_worktree_env.hooks.current_hooks_path", lambda: str(existing)
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 git_worktree_env.projector import apply_worktree
4
- from git_worktree_env.registry import load_registry
5
- from git_worktree_env.utils import WteError
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("git_worktree_env.registry.port_is_free", lambda _port: True)
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("git_worktree_env.registry.port_is_free", lambda _port: True)
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("git_worktree_env.projector.apply_writes", fail_writes)
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 git_worktree_env.reconciler import (
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 git_worktree_env.registry import load_registry
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("git_worktree_env.registry.port_is_free", lambda _port: True)
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
- "git_worktree_env.reconciler._launch_agent_path", lambda: agent
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
- "git_worktree_env.reconciler._systemd_user_dir", lambda: unit_dir
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 git_worktree_env.config import PortPool
7
- from git_worktree_env.registry import (
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 git_worktree_env.utils import WteError
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("git_worktree_env.registry.port_is_free", lambda _port: True)
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 = {
@@ -258,7 +258,7 @@ wheels = [
258
258
 
259
259
  [[package]]
260
260
  name = "worktree-env"
261
- version = "0.2.0"
261
+ version = "0.3.1"
262
262
  source = { editable = "." }
263
263
  dependencies = [
264
264
  { name = "pyyaml" },
@@ -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,8 +0,0 @@
1
- """Console-script bridge from git-worktree-env to worktree-env."""
2
-
3
-
4
- def main() -> int:
5
- """Delegate to the CLI supplied by the renamed distribution."""
6
- from git_worktree_env.cli import main as worktree_env_main
7
-
8
- return worktree_env_main()
@@ -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