workforest 0.1.2__tar.gz → 0.2.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 (47) hide show
  1. {workforest-0.1.2 → workforest-0.2.1}/.github/workflows/publish_aur.yml +6 -0
  2. {workforest-0.1.2 → workforest-0.2.1}/.github/workflows/publish_pypi.yml +3 -1
  3. {workforest-0.1.2 → workforest-0.2.1}/PKG-INFO +25 -7
  4. {workforest-0.1.2 → workforest-0.2.1}/README.md +23 -5
  5. {workforest-0.1.2 → workforest-0.2.1}/packaging/AUR/.SRCINFO +4 -4
  6. {workforest-0.1.2 → workforest-0.2.1}/packaging/AUR/PKGBUILD +2 -2
  7. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/__init__.py +1 -1
  8. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/cli.py +3 -1
  9. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/commands.py +6 -2
  10. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/examples/config.yaml +42 -18
  11. workforest-0.2.1/src/workforest/launch.py +162 -0
  12. {workforest-0.1.2 → workforest-0.2.1}/tests/conftest.py +3 -1
  13. {workforest-0.1.2 → workforest-0.2.1}/tests/test_cli.py +5 -4
  14. {workforest-0.1.2 → workforest-0.2.1}/tests/test_commands.py +15 -8
  15. {workforest-0.1.2 → workforest-0.2.1}/tests/test_config.py +2 -2
  16. workforest-0.2.1/tests/test_launch.py +204 -0
  17. {workforest-0.1.2 → workforest-0.2.1}/tests/test_shell.py +1 -1
  18. {workforest-0.1.2 → workforest-0.2.1}/tests/test_tui.py +1 -1
  19. workforest-0.1.2/src/workforest/launch.py +0 -107
  20. workforest-0.1.2/tests/test_launch.py +0 -113
  21. {workforest-0.1.2 → workforest-0.2.1}/.github/workflows/ci.yml +0 -0
  22. {workforest-0.1.2 → workforest-0.2.1}/.github/workflows/release.yml +0 -0
  23. {workforest-0.1.2 → workforest-0.2.1}/.gitignore +0 -0
  24. {workforest-0.1.2 → workforest-0.2.1}/LICENSE +0 -0
  25. {workforest-0.1.2 → workforest-0.2.1}/Makefile +0 -0
  26. {workforest-0.1.2 → workforest-0.2.1}/completions/_workforest +0 -0
  27. {workforest-0.1.2 → workforest-0.2.1}/pyproject.toml +0 -0
  28. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/__main__.py +0 -0
  29. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/completions.py +0 -0
  30. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/config.py +0 -0
  31. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/errors.py +0 -0
  32. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/gitutil.py +0 -0
  33. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/hooks.py +0 -0
  34. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/integrations/__init__.py +0 -0
  35. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/integrations/claude.py +0 -0
  36. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/output.py +0 -0
  37. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/shell/completion.bash +0 -0
  38. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/shell/completion.zsh +0 -0
  39. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/shell/workforest.sh +0 -0
  40. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/shellinit.py +0 -0
  41. {workforest-0.1.2 → workforest-0.2.1}/src/workforest/tui.py +0 -0
  42. {workforest-0.1.2 → workforest-0.2.1}/tests/__init__.py +0 -0
  43. {workforest-0.1.2 → workforest-0.2.1}/tests/test_claude.py +0 -0
  44. {workforest-0.1.2 → workforest-0.2.1}/tests/test_gitutil.py +0 -0
  45. {workforest-0.1.2 → workforest-0.2.1}/tests/test_harness.py +0 -0
  46. {workforest-0.1.2 → workforest-0.2.1}/tests/test_hooks.py +0 -0
  47. {workforest-0.1.2 → workforest-0.2.1}/uv.lock +0 -0
@@ -8,6 +8,9 @@ jobs:
8
8
  publish:
9
9
  runs-on: ubuntu-latest
10
10
  container: archlinux:latest
11
+ environment:
12
+ name: aur
13
+ url: https://aur.archlinux.org/packages/workforest
11
14
  permissions:
12
15
  contents: write # commit the generated PKGBUILD back to main
13
16
  steps:
@@ -72,6 +75,9 @@ jobs:
72
75
  run: |
73
76
  version="${GITHUB_REF_NAME#v}"
74
77
  cp packaging/AUR/PKGBUILD packaging/AUR/.SRCINFO /tmp/
78
+ # The workspace is created by the host runner under a different UID
79
+ # than the container's root, so git rejects it without this.
80
+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
75
81
  # The workflow runs detached at the release tag; move to main to commit.
76
82
  # -f discards the in-place edits we just copied to /tmp; build
77
83
  # artifacts in packaging/AUR are untracked and never git-added.
@@ -7,7 +7,9 @@ on:
7
7
  jobs:
8
8
  publish:
9
9
  runs-on: ubuntu-latest
10
- environment: pypi
10
+ environment:
11
+ name: pypi
12
+ url: https://pypi.org/project/workforest/
11
13
  permissions:
12
14
  id-token: write # OIDC for PyPI trusted publishing
13
15
  steps:
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: workforest
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Git worktree forest management: create, open, and clean up per-branch worktrees with project-defined setup hooks
5
5
  Project-URL: Homepage, https://github.com/ArkadyBuryakov/workforest
6
6
  Author-email: Arkady Buryakov <arkady@buryakov.pro>
@@ -103,17 +103,35 @@ All keys, with defaults:
103
103
  ```yaml
104
104
  worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME" # where the forest lives
105
105
  opener: "" # default opener; "" → $VISUAL → $EDITOR
106
- openers: {} # name -> command template, e.g. edit: "$EDITOR {path}"
106
+ openers: {} # name -> command template, e.g. edit: "$EDITOR {target}"
107
107
  window_command: "" # "" → current shell; or e.g.
108
- # "kitty --title {title} --directory {path} {command}"
108
+ # "kitty --title {title} --directory {worktree} $WF_COMMAND"
109
109
  symlinks: [] # untracked assets linked from main into new worktrees
110
110
  setup_scripts: [] # shell snippets run in a fresh worktree
111
111
  scripts: {} # name -> snippet for `wf run NAME`
112
112
  ```
113
113
 
114
- Openers are command templates: `{path}` is replaced with the target path; a
115
- template without `{path}` just runs with the worktree as working directory.
116
- Environment variables expand in both openers and `window_command`.
114
+ Openers and `window_command` are command templates sharing one variable
115
+ family, which the launched process (and every script) also receives as
116
+ environment variables:
117
+
118
+ | Variable | Value |
119
+ |---|---|
120
+ | `WF_MAIN` | main worktree path, `/home/user/Projects/project_name` |
121
+ | `WF_NAME` | repo name, `project_name` |
122
+ | `WF_WORKTREES_DIR` | resolved worktrees directory |
123
+ | `WF_WORKTREE` | this worktree's path |
124
+ | `WF_BRANCH` | its branch (empty if detached) |
125
+ | `WF_TARGET` | the `-p` argument, default `.` (launch-only) |
126
+ | `WF_TITLE` | window label, `project_name: feat-x` (launch-only) |
127
+
128
+ In templates, `$WF_X` (like any `$ENV` variable) inserts raw text that
129
+ word-splits into multiple arguments, while `{x}` — `{worktree}`, `{target}`,
130
+ `{title}`, … — inserts the shell-quoted value as exactly one argument.
131
+ Openers run with the worktree root as working directory; in
132
+ `window_command` the resolved opener command is additionally available as
133
+ `$WF_COMMAND` (spliced into argv words) or `{command}` (one argument, for
134
+ `$SHELL -c` wrappers).
117
135
 
118
136
  Fully commented reference configs:
119
137
  [`config.yaml`](src/workforest/examples/config.yaml) (user/system) and
@@ -84,17 +84,35 @@ All keys, with defaults:
84
84
  ```yaml
85
85
  worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME" # where the forest lives
86
86
  opener: "" # default opener; "" → $VISUAL → $EDITOR
87
- openers: {} # name -> command template, e.g. edit: "$EDITOR {path}"
87
+ openers: {} # name -> command template, e.g. edit: "$EDITOR {target}"
88
88
  window_command: "" # "" → current shell; or e.g.
89
- # "kitty --title {title} --directory {path} {command}"
89
+ # "kitty --title {title} --directory {worktree} $WF_COMMAND"
90
90
  symlinks: [] # untracked assets linked from main into new worktrees
91
91
  setup_scripts: [] # shell snippets run in a fresh worktree
92
92
  scripts: {} # name -> snippet for `wf run NAME`
93
93
  ```
94
94
 
95
- Openers are command templates: `{path}` is replaced with the target path; a
96
- template without `{path}` just runs with the worktree as working directory.
97
- Environment variables expand in both openers and `window_command`.
95
+ Openers and `window_command` are command templates sharing one variable
96
+ family, which the launched process (and every script) also receives as
97
+ environment variables:
98
+
99
+ | Variable | Value |
100
+ |---|---|
101
+ | `WF_MAIN` | main worktree path, `/home/user/Projects/project_name` |
102
+ | `WF_NAME` | repo name, `project_name` |
103
+ | `WF_WORKTREES_DIR` | resolved worktrees directory |
104
+ | `WF_WORKTREE` | this worktree's path |
105
+ | `WF_BRANCH` | its branch (empty if detached) |
106
+ | `WF_TARGET` | the `-p` argument, default `.` (launch-only) |
107
+ | `WF_TITLE` | window label, `project_name: feat-x` (launch-only) |
108
+
109
+ In templates, `$WF_X` (like any `$ENV` variable) inserts raw text that
110
+ word-splits into multiple arguments, while `{x}` — `{worktree}`, `{target}`,
111
+ `{title}`, … — inserts the shell-quoted value as exactly one argument.
112
+ Openers run with the worktree root as working directory; in
113
+ `window_command` the resolved opener command is additionally available as
114
+ `$WF_COMMAND` (spliced into argv words) or `{command}` (one argument, for
115
+ `$SHELL -c` wrappers).
98
116
 
99
117
  Fully commented reference configs:
100
118
  [`config.yaml`](src/workforest/examples/config.yaml) (user/system) and
@@ -1,8 +1,8 @@
1
1
  pkgbase = workforest
2
2
  pkgdesc = Git worktree forest management: per-branch worktrees with project-defined setup hooks
3
- pkgver = 0.1.0
3
+ pkgver = 0.2.0
4
4
  pkgrel = 1
5
- url = https://github.com/arkadyb/workforest
5
+ url = https://github.com/ArkadyBuryakov/workforest
6
6
  arch = any
7
7
  license = MIT
8
8
  makedepends = python-build
@@ -13,7 +13,7 @@ pkgbase = workforest
13
13
  depends = python-yaml
14
14
  depends = git
15
15
  optdepends = fzf: interactive TUI (workforest tui)
16
- source = workforest-0.1.0.tar.gz::https://github.com/arkadyb/workforest/archive/v0.1.0.tar.gz
17
- sha256sums = SKIP
16
+ source = workforest-0.2.0.tar.gz::https://github.com/ArkadyBuryakov/workforest/archive/v0.2.0.tar.gz
17
+ sha256sums = dc1ba2518e2612714e023f7fc088aeaa37f9e0d5bffbcbc07c2dd15f109bb476
18
18
 
19
19
  pkgname = workforest
@@ -1,6 +1,6 @@
1
1
  # Maintainer: Arkady Buryakov <arkady@buryakov.pro>
2
2
  pkgname=workforest
3
- pkgver=0.1.0
3
+ pkgver=0.2.0
4
4
  pkgrel=1
5
5
  pkgdesc="Git worktree forest management: per-branch worktrees with project-defined setup hooks"
6
6
  arch=(any)
@@ -10,7 +10,7 @@ depends=(python python-yaml git)
10
10
  makedepends=(python-build python-installer python-wheel python-hatchling)
11
11
  optdepends=('fzf: interactive TUI (workforest tui)')
12
12
  source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
13
- sha256sums=('9998d8fc554623ed905224feb9e2aa5734f8da44384331c3d4540909ac246be8')
13
+ sha256sums=('dc1ba2518e2612714e023f7fc088aeaa37f9e0d5bffbcbc07c2dd15f109bb476')
14
14
 
15
15
  build() {
16
16
  cd "$pkgname-$pkgver"
@@ -1,3 +1,3 @@
1
1
  """Workforest — git worktree forest management."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.1"
@@ -140,7 +140,9 @@ def build_parser() -> argparse.ArgumentParser:
140
140
 
141
141
  def opener_args(p: argparse.ArgumentParser) -> None:
142
142
  p.add_argument("-o", "--opener", help="opener name or command template")
143
- p.add_argument("-p", "--path", help="path inside the worktree to open")
143
+ p.add_argument(
144
+ "-p", "--path", help="path inside the worktree, passed to the opener as {target}"
145
+ )
144
146
 
145
147
  p = sub.add_parser("create", help="create (or reuse) a worktree for a branch and open it")
146
148
  p.add_argument("branch", nargs="?", help="branch name (default: current branch)")
@@ -115,8 +115,10 @@ def cmd_create(
115
115
  return None
116
116
  return launch.launch(
117
117
  ctx.config,
118
+ main=ctx.main,
118
119
  worktree=worktree_path,
119
- repo_name=ctx.repo_name,
120
+ worktrees_dir=ctx.worktrees_dir,
121
+ branch=branch,
120
122
  opener_arg=opener,
121
123
  path_arg=path_arg,
122
124
  )
@@ -134,8 +136,10 @@ def cmd_open(
134
136
  worktree = find_managed(ctx, name)
135
137
  return launch.launch(
136
138
  ctx.config,
139
+ main=ctx.main,
137
140
  worktree=worktree.path,
138
- repo_name=ctx.repo_name,
141
+ worktrees_dir=ctx.worktrees_dir,
142
+ branch=worktree.branch,
139
143
  opener_arg=opener,
140
144
  path_arg=path_arg,
141
145
  )
@@ -35,6 +35,32 @@ worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME"
35
35
  # worktrees_dir: "$HOME/worktrees/$WF_NAME" # all forests in one place
36
36
  # worktrees_dir: "$WF_MAIN/../worktrees" # bash-MVP flat layout
37
37
 
38
+ # ---------------------------------------------------------------------------
39
+ # The WF_* variable family — one set of variables, available everywhere.
40
+ #
41
+ # $WF_MAIN main worktree path /home/user/Projects/project_name
42
+ # $WF_NAME repo name project_name
43
+ # $WF_WORKTREES_DIR resolved worktrees dir /home/user/Projects/worktrees/project_name
44
+ # $WF_WORKTREE this worktree's path .../worktrees/project_name/feat-x
45
+ # $WF_BRANCH its branch feat/x (empty if detached)
46
+ # $WF_TARGET the -p argument src/foo.py (default ".")
47
+ # $WF_TITLE window label "project_name: feat-x"
48
+ #
49
+ # In `opener`/`openers`/`window_command` templates, workforest expands them
50
+ # itself, in two forms:
51
+ # * $WF_X (and any $ENV var) raw text — word-splits into multiple arguments
52
+ # * {x} (e.g. {worktree}) the shell-quoted value of $WF_X — always
53
+ # exactly ONE argument; use this unless you
54
+ # specifically want splitting
55
+ # The launched process (editor, terminal, scripts they spawn) also receives
56
+ # the family as real environment variables.
57
+ #
58
+ # Exceptions to "everywhere":
59
+ # * `worktrees_dir` sees only $WF_MAIN/$WF_NAME (the rest don't exist yet)
60
+ # * scripts don't get WF_TARGET/WF_TITLE (launch-only concepts)
61
+ # * your interactive shell never gets them (they're scoped to the opener)
62
+ # ---------------------------------------------------------------------------
63
+
38
64
  # ---------------------------------------------------------------------------
39
65
  # Default opener — what runs when you `wf create`/`wf open` without -o.
40
66
  #
@@ -47,17 +73,15 @@ worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME"
47
73
  opener: ""
48
74
 
49
75
  # opener: edit # a name defined in `openers`
50
- # opener: "$EDITOR {path}" # or an inline template
76
+ # opener: "$EDITOR {target}" # or an inline template
51
77
 
52
78
  # ---------------------------------------------------------------------------
53
79
  # Named openers — command templates for -o NAME, the shortcut form
54
80
  # (`wf NAME <worktree>`), and the TUI opener carousel.
55
81
  #
56
- # Template rules:
57
- # * environment variables are expanded ($EDITOR, $SHELL, …)
58
- # * {path} is replaced with the shell-quoted target path
59
- # * a template WITHOUT {path} simply runs with the target directory as its
60
- # working directory
82
+ # Openers always run with the worktree root as working directory; {target}
83
+ # ($WF_TARGET) is the -p argument, "." by default. A template without
84
+ # {target} simply runs in the worktree.
61
85
  #
62
86
  # Default: {} — the TUI then falls back to two derived entries:
63
87
  # the default opener and $SHELL.
@@ -65,40 +89,40 @@ opener: ""
65
89
  openers: {}
66
90
 
67
91
  # openers:
68
- # edit: "$EDITOR {path}" # editors usually want the path argument
92
+ # edit: "$EDITOR {target}" # editors usually want the path argument
69
93
  # shell: "$SHELL" # just a shell in the worktree
70
94
  # git: "lazygit" # runs with cwd = worktree, no argument
71
- # ide: "xdg-open {path}" # hand off to the desktop default handler
95
+ # ide: "xdg-open {target}" # hand off to the desktop default handler
72
96
  # agent: "claude"
73
97
 
74
98
  # ---------------------------------------------------------------------------
75
99
  # Window command — WHERE the opener runs.
76
100
  #
77
101
  # Default: "" — the opener runs in your current shell via the `wf` wrapper
78
- # (`cd <worktree> && <opener>`).
102
+ # (`cd <worktree> && WF_*=... <opener>`).
79
103
  #
80
104
  # Set a template to spawn a new terminal window / multiplexer pane instead.
81
- # Placeholders: {title} "repo: worktree", shell-quoted
82
- # {path} target directory
83
- # {command} the resolved opener command
84
- # The process is spawned fully detached.
105
+ # The process is spawned fully detached. Besides the WF_* family, the
106
+ # resolved opener command is available here as:
107
+ # $WF_COMMAND spliced into separate argv words (most terminals)
108
+ # {command} ONE argument — what `$SHELL -c` wrappers want
85
109
  #
86
110
  # This key is the environment-specific bit par excellence — it belongs in
87
111
  # your USER config, never in a project config.
88
112
  # ---------------------------------------------------------------------------
89
113
  window_command: ""
90
114
 
91
- # window_command: "kitty --title {title} --directory {path} {command}"
92
- # window_command: "foot --title={title} --working-directory={path} {command}"
93
- # window_command: "alacritty --title {title} --working-directory {path} -e {command}"
94
- # window_command: "tmux new-window -c {path} -n {title} {command}"
115
+ # window_command: "kitty --title {title} --directory {worktree} $WF_COMMAND"
116
+ # window_command: "foot --title={title} --working-directory={worktree} $WF_COMMAND"
117
+ # window_command: "alacritty --title {title} --working-directory {worktree} -e $SHELL -c {command}"
118
+ # window_command: "tmux new-window -c {worktree} -n {title} {command}"
95
119
 
96
120
  # ---------------------------------------------------------------------------
97
121
  # Personal named scripts — available via `workforest run NAME` in every repo.
98
122
  # Merged per key with project `scripts` (project wins on conflict).
99
123
  #
100
124
  # Scripts run from the current worktree root via $SHELL -c with:
101
- # WF_MAIN, WF_WORKTREE, WF_WORKTREES_DIR, WF_BRANCH
125
+ # WF_MAIN, WF_NAME, WF_WORKTREE, WF_WORKTREES_DIR, WF_BRANCH
102
126
  #
103
127
  # Default: {}
104
128
  # ---------------------------------------------------------------------------
@@ -0,0 +1,162 @@
1
+ """Opener/window template resolution and cd-protocol emission (DESIGN §3.4).
2
+
3
+ No tool names appear here: what to run comes from config templates and the
4
+ $VISUAL/$EDITOR contract; where to run it is the current shell (ShellAction)
5
+ unless the user configured a window_command template.
6
+
7
+ Templates and launched processes share one variable family, WF_*:
8
+
9
+ * `$WF_X` (and any environment variable) is substituted as raw text at
10
+ template time, so it word-splits into multiple arguments;
11
+ * `{x}` is the shell-quoted form of `$WF_X` — always exactly one argument;
12
+ * the launched process receives the same family as environment variables
13
+ (Popen env in the window path, prefix assignments in the shell path).
14
+ """
15
+
16
+ import os
17
+ import re
18
+ import shlex
19
+ import string
20
+ import subprocess
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from workforest.config import Config
25
+ from workforest.errors import WorkforestError
26
+
27
+ _PLACEHOLDER = re.compile(r"\{([a-z_]+)\}")
28
+
29
+
30
+ @dataclass(slots=True, frozen=True)
31
+ class ShellAction:
32
+ """A directive for the wf shell wrapper; the only thing cli.py prints
33
+ to stdout for create/open/checkout."""
34
+
35
+ script: str
36
+
37
+
38
+ def cd_action(path: Path) -> ShellAction:
39
+ return ShellAction(f"cd {shlex.quote(str(path))}")
40
+
41
+
42
+ def resolve_opener_template(config: Config, opener_arg: str | None) -> str:
43
+ """-o NAME → `openers` lookup, else verbatim; default $VISUAL → $EDITOR."""
44
+ value = opener_arg or config.opener
45
+ if value:
46
+ return config.openers.get(value, value)
47
+ for var in ("VISUAL", "EDITOR"):
48
+ if os.environ.get(var):
49
+ return os.environ[var]
50
+ raise WorkforestError(
51
+ "no opener: pass -o, set `opener` in a config file, or export $VISUAL/$EDITOR"
52
+ )
53
+
54
+
55
+ def launch_vars(
56
+ *,
57
+ main: Path,
58
+ worktree: Path,
59
+ worktrees_dir: Path,
60
+ branch: str | None,
61
+ target: str,
62
+ ) -> dict[str, str]:
63
+ """The full WF_* family for a launch (DESIGN §3.5/§3.6): the scripts'
64
+ structural five plus the launch-only WF_TARGET and WF_TITLE."""
65
+ return {
66
+ "WF_MAIN": str(main),
67
+ "WF_NAME": main.name,
68
+ "WF_WORKTREES_DIR": str(worktrees_dir),
69
+ "WF_WORKTREE": str(worktree),
70
+ "WF_BRANCH": branch or "",
71
+ "WF_TARGET": target,
72
+ "WF_TITLE": f"{main.name}: {worktree.name}",
73
+ }
74
+
75
+
76
+ def expand_template(template: str, variables: dict[str, str]) -> str:
77
+ """`$WF_X`/`$ENV` insert raw text; `{x}` inserts $WF_X shell-quoted.
78
+
79
+ Raw substitution never touches the quoted insertions: the template is
80
+ split on `{x}` placeholders and only the literal segments go through
81
+ string.Template, so values containing `$` or braces are inert.
82
+ """
83
+ mapping = {**os.environ, **variables}
84
+ quoted = {name.removeprefix("WF_").lower(): value for name, value in variables.items()}
85
+ parts: list[str] = []
86
+ pos = 0
87
+ for match in _PLACEHOLDER.finditer(template):
88
+ parts.append(string.Template(template[pos : match.start()]).safe_substitute(mapping))
89
+ name = match.group(1)
90
+ if name not in quoted:
91
+ known = ", ".join("{" + key + "}" for key in sorted(quoted))
92
+ raise WorkforestError(f"unknown placeholder {{{name}}} (known: {known})")
93
+ parts.append(shlex.quote(quoted[name]))
94
+ pos = match.end()
95
+ parts.append(string.Template(template[pos:]).safe_substitute(mapping))
96
+ return "".join(parts)
97
+
98
+
99
+ def launch(
100
+ config: Config,
101
+ *,
102
+ main: Path,
103
+ worktree: Path,
104
+ worktrees_dir: Path,
105
+ branch: str | None,
106
+ opener_arg: str | None = None,
107
+ path_arg: str | None = None,
108
+ ) -> ShellAction | None:
109
+ """Open a worktree: a ShellAction for the current shell, or a detached
110
+ window spawn (returning None) when window_command is configured.
111
+
112
+ The launch cwd is always the worktree root; -p only sets WF_TARGET,
113
+ the opener's argument.
114
+ """
115
+ template = resolve_opener_template(config, opener_arg)
116
+ target = path_arg if path_arg else "."
117
+ variables = launch_vars(
118
+ main=main,
119
+ worktree=worktree,
120
+ worktrees_dir=worktrees_dir,
121
+ branch=branch,
122
+ target=target,
123
+ )
124
+ command = expand_template(template, variables)
125
+ if config.window_command:
126
+ spawn_window(config.window_command, variables=variables, command=command, cwd=worktree)
127
+ return None
128
+ # Prefix assignments scope WF_* to the opener command alone — nothing
129
+ # leaks into (or goes stale in) the user's interactive shell.
130
+ assignments = " ".join(f"{name}={shlex.quote(value)}" for name, value in variables.items())
131
+ return ShellAction(f"cd {shlex.quote(str(worktree))} && {assignments} {command}")
132
+
133
+
134
+ def spawn_window(
135
+ window_template: str,
136
+ *,
137
+ variables: dict[str, str],
138
+ command: str,
139
+ cwd: Path,
140
+ ) -> None:
141
+ """Spawn the user's window_command fully detached (DESIGN §3.4).
142
+
143
+ The resolved opener command joins the family as {command} (one argument,
144
+ e.g. for `$SHELL -c {command}`) / `$WF_COMMAND` (spliced into argv words);
145
+ it is template-only and not exported to the environment.
146
+ """
147
+ expanded = expand_template(window_template, {**variables, "WF_COMMAND": command})
148
+ argv = shlex.split(expanded)
149
+ if not argv:
150
+ raise WorkforestError("window_command expanded to an empty command")
151
+ try:
152
+ subprocess.Popen(
153
+ argv,
154
+ cwd=cwd,
155
+ env={**os.environ, **variables},
156
+ start_new_session=True,
157
+ stdin=subprocess.DEVNULL,
158
+ stdout=subprocess.DEVNULL,
159
+ stderr=subprocess.DEVNULL,
160
+ )
161
+ except FileNotFoundError as exc:
162
+ raise WorkforestError(f"window_command program not found: {argv[0]!r}") from exc
@@ -149,7 +149,9 @@ class Recorder:
149
149
  def recorder(tmp_path: Path) -> Recorder:
150
150
  log = tmp_path / "recorder.log"
151
151
  script = tmp_path / "recorder"
152
- script.write_text(f'#!/bin/sh\necho "argv=$* cwd=$PWD" >> {log}\n')
152
+ script.write_text(
153
+ f'#!/bin/sh\necho "argv=$* argc=$# cwd=$PWD wf_worktree=$WF_WORKTREE" >> {log}\n'
154
+ )
153
155
  script.chmod(script.stat().st_mode | stat.S_IXUSR)
154
156
  return Recorder(path=script, log=log)
155
157
 
@@ -46,7 +46,8 @@ class TestStdoutContract:
46
46
  lines = result.out.splitlines()
47
47
  assert len(lines) == 1
48
48
  assert lines[0].startswith("cd ")
49
- assert lines[0].endswith("&& stub-editor")
49
+ assert lines[0].endswith(" stub-editor")
50
+ assert "WF_MAIN=" in lines[0] # WF_* rides along as prefix assignments
50
51
  assert "created worktree" in result.err # human messages on stderr
51
52
 
52
53
  def test_no_open_emits_nothing(self, run_cli: Run, repo: Repo) -> None:
@@ -79,13 +80,13 @@ class TestShortcut:
79
80
  run_cli("create", "feat", "--no-open", cwd=repo.path)
80
81
  result = run_cli("mytool", "feat", cwd=repo.path)
81
82
  assert result.code == 0
82
- assert result.out.strip().endswith("&& mytool")
83
+ assert result.out.strip().endswith(" mytool")
83
84
 
84
85
  def test_shortcut_passes_path(self, run_cli: Run, repo: Repo) -> None:
85
86
  run_cli("create", "feat", "--no-open", cwd=repo.path)
86
- result = run_cli("mytool {path}", "feat", "-p", "README.md", cwd=repo.path)
87
+ result = run_cli("mytool {target}", "feat", "-p", "README.md", cwd=repo.path)
87
88
  assert result.code == 0
88
- assert result.out.strip().endswith("&& mytool README.md")
89
+ assert result.out.strip().endswith(" mytool README.md")
89
90
 
90
91
 
91
92
  class TestExitCodes:
@@ -49,7 +49,9 @@ class TestCreate:
49
49
  assert worktree_path.is_dir()
50
50
  assert gitutil.current_branch(worktree_path) == "feature/cool-thing"
51
51
  assert isinstance(action, ShellAction)
52
- assert action.script == f"cd {worktree_path} && stub-editor"
52
+ assert action.script.startswith(f"cd {worktree_path} && WF_MAIN=")
53
+ assert action.script.endswith(" stub-editor")
54
+ assert "WF_BRANCH=feature/cool-thing" in action.script
53
55
 
54
56
  def test_existing_local_branch(self, repo: Repo) -> None:
55
57
  repo.add_branch("existing")
@@ -76,7 +78,8 @@ class TestCreate:
76
78
  ctx = ctx_for(repo)
77
79
  action = commands.cmd_create(ctx, None)
78
80
  assert isinstance(action, ShellAction)
79
- assert action.script == f"cd {repo.path} && stub-editor"
81
+ assert action.script.startswith(f"cd {repo.path} && WF_MAIN=")
82
+ assert action.script.endswith(" stub-editor")
80
83
 
81
84
  def test_short_name_collision_errors(self, repo: Repo) -> None:
82
85
  ctx = ctx_for(repo)
@@ -120,22 +123,26 @@ class TestOpen:
120
123
  commands.cmd_create(ctx, "feat", no_open=True)
121
124
  action = commands.cmd_open(ctx, "feat")
122
125
  assert isinstance(action, ShellAction)
123
- assert action.script == f"cd {ctx.worktrees_dir / 'feat'} && stub-editor"
126
+ assert action.script.startswith(f"cd {ctx.worktrees_dir / 'feat'} && WF_MAIN=")
127
+ assert action.script.endswith(" stub-editor")
124
128
 
125
- def test_open_subdirectory(self, repo: Repo) -> None:
129
+ def test_open_subdirectory_stays_at_root(self, repo: Repo) -> None:
130
+ # -p never changes the launch cwd; it only sets WF_TARGET.
126
131
  ctx = ctx_for(repo)
127
132
  commands.cmd_create(ctx, "feat", no_open=True)
128
133
  (ctx.worktrees_dir / "feat" / "src").mkdir()
129
134
  action = commands.cmd_open(ctx, "feat", path_arg="src")
130
135
  assert isinstance(action, ShellAction)
131
- assert action.script == f"cd {ctx.worktrees_dir / 'feat' / 'src'} && stub-editor"
136
+ assert action.script.startswith(f"cd {ctx.worktrees_dir / 'feat'} && ")
137
+ assert "WF_TARGET=src" in action.script
132
138
 
133
- def test_open_file_with_path_template(self, repo: Repo) -> None:
139
+ def test_open_file_with_target_template(self, repo: Repo) -> None:
134
140
  ctx = ctx_for(repo)
135
141
  commands.cmd_create(ctx, "feat", no_open=True)
136
- action = commands.cmd_open(ctx, "feat", opener="tool {path}", path_arg="README.md")
142
+ action = commands.cmd_open(ctx, "feat", opener="tool {target}", path_arg="README.md")
137
143
  assert isinstance(action, ShellAction)
138
- assert action.script == f"cd {ctx.worktrees_dir / 'feat'} && tool README.md"
144
+ assert action.script.startswith(f"cd {ctx.worktrees_dir / 'feat'} && ")
145
+ assert action.script.endswith(" tool README.md")
139
146
 
140
147
  def test_open_unknown_errors(self, repo: Repo) -> None:
141
148
  with pytest.raises(WorkforestError, match="'nope' not found"):
@@ -129,10 +129,10 @@ class TestMergeSemantics:
129
129
  assert load_config(project).scripts == {}
130
130
 
131
131
  def test_openers_merge_like_scripts(self, tmp_path: Path) -> None:
132
- write_system_config("openers:\n edit: $EDITOR {path}\n")
132
+ write_system_config("openers:\n edit: $EDITOR {target}\n")
133
133
  write_user_config("openers:\n git: lazygit\n")
134
134
  cfg = load_config()
135
- assert cfg.openers == {"edit": "$EDITOR {path}", "git": "lazygit"}
135
+ assert cfg.openers == {"edit": "$EDITOR {target}", "git": "lazygit"}
136
136
 
137
137
 
138
138
  class TestEnvOverrides:
@@ -0,0 +1,204 @@
1
+ """launch: opener/window template resolution and the cd protocol."""
2
+
3
+ import shlex
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from workforest import launch
9
+ from workforest.config import Config
10
+ from workforest.errors import WorkforestError
11
+
12
+ from .conftest import Recorder
13
+
14
+
15
+ class TestOpenerResolution:
16
+ def test_named_opener_from_config(self) -> None:
17
+ cfg = Config(openers={"edit": "$EDITOR {target}"})
18
+ assert launch.resolve_opener_template(cfg, "edit") == "$EDITOR {target}"
19
+
20
+ def test_unknown_name_used_verbatim(self) -> None:
21
+ cfg = Config(openers={"edit": "$EDITOR {target}"})
22
+ assert launch.resolve_opener_template(cfg, "my-tool --flag") == "my-tool --flag"
23
+
24
+ def test_config_default_opener(self) -> None:
25
+ cfg = Config(opener="edit", openers={"edit": "$EDITOR {target}"})
26
+ assert launch.resolve_opener_template(cfg, None) == "$EDITOR {target}"
27
+
28
+ def test_visual_beats_editor(self, monkeypatch: pytest.MonkeyPatch) -> None:
29
+ monkeypatch.setenv("VISUAL", "visual-tool")
30
+ assert launch.resolve_opener_template(Config(), None) == "visual-tool"
31
+
32
+ def test_editor_fallback(self) -> None:
33
+ # EDITOR is pinned to stub-editor by the isolation fixture
34
+ assert launch.resolve_opener_template(Config(), None) == "stub-editor"
35
+
36
+ def test_no_opener_anywhere_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
37
+ monkeypatch.delenv("EDITOR")
38
+ with pytest.raises(WorkforestError, match="no opener"):
39
+ launch.resolve_opener_template(Config(), None)
40
+
41
+
42
+ class TestLaunchVars:
43
+ def test_full_family(self, tmp_path: Path) -> None:
44
+ variables = launch.launch_vars(
45
+ main=tmp_path / "api",
46
+ worktree=tmp_path / "worktrees" / "api" / "feat",
47
+ worktrees_dir=tmp_path / "worktrees" / "api",
48
+ branch="feat/x",
49
+ target="src/foo.py",
50
+ )
51
+ assert variables == {
52
+ "WF_MAIN": str(tmp_path / "api"),
53
+ "WF_NAME": "api",
54
+ "WF_WORKTREES_DIR": str(tmp_path / "worktrees" / "api"),
55
+ "WF_WORKTREE": str(tmp_path / "worktrees" / "api" / "feat"),
56
+ "WF_BRANCH": "feat/x",
57
+ "WF_TARGET": "src/foo.py",
58
+ "WF_TITLE": "api: feat",
59
+ }
60
+
61
+ def test_detached_branch_is_empty(self, tmp_path: Path) -> None:
62
+ variables = launch.launch_vars(
63
+ main=tmp_path / "api",
64
+ worktree=tmp_path / "feat",
65
+ worktrees_dir=tmp_path,
66
+ branch=None,
67
+ target=".",
68
+ )
69
+ assert variables["WF_BRANCH"] == ""
70
+
71
+
72
+ class TestTemplateExpansion:
73
+ def test_placeholder_is_one_quoted_argument(self) -> None:
74
+ result = launch.expand_template("tool {target}", {"WF_TARGET": "a file.txt"})
75
+ assert result == "tool 'a file.txt'"
76
+
77
+ def test_dollar_form_is_raw_and_word_splits(self) -> None:
78
+ result = launch.expand_template("tool $WF_TARGET", {"WF_TARGET": "--flag one"})
79
+ assert result == "tool --flag one"
80
+
81
+ def test_env_expansion(self, monkeypatch: pytest.MonkeyPatch) -> None:
82
+ monkeypatch.setenv("MYTOOL", "actual-tool")
83
+ assert launch.expand_template("$MYTOOL {target}", {"WF_TARGET": "."}) == "actual-tool ."
84
+
85
+ def test_unknown_env_left_alone(self) -> None:
86
+ assert launch.expand_template("tool $NO_SUCH_VAR", {}) == "tool $NO_SUCH_VAR"
87
+
88
+ def test_unknown_placeholder_errors_with_known_list(self) -> None:
89
+ with pytest.raises(WorkforestError, match=r"unknown placeholder \{path\}.*\{target\}"):
90
+ launch.expand_template("tool {path}", {"WF_TARGET": "."})
91
+
92
+ def test_values_are_inert(self) -> None:
93
+ # A substituted value must never be re-expanded, even if it looks
94
+ # like a template itself.
95
+ result = launch.expand_template("edit {target}", {"WF_TARGET": "$HOME/{title}"})
96
+ assert result == "edit '$HOME/{title}'"
97
+
98
+ def test_no_placeholder_left_alone(self) -> None:
99
+ assert launch.expand_template("plain-tool --flag", {"WF_TARGET": "."}) == (
100
+ "plain-tool --flag"
101
+ )
102
+
103
+
104
+ def run_launch(
105
+ cfg: Config,
106
+ tmp_path: Path,
107
+ *,
108
+ branch: str | None = "feat",
109
+ opener_arg: str | None = None,
110
+ path_arg: str | None = None,
111
+ ) -> tuple[launch.ShellAction | None, Path]:
112
+ main = tmp_path / "api"
113
+ worktree = tmp_path / "worktrees" / "api" / "feat"
114
+ worktree.mkdir(parents=True, exist_ok=True)
115
+ action = launch.launch(
116
+ cfg,
117
+ main=main,
118
+ worktree=worktree,
119
+ worktrees_dir=worktree.parent,
120
+ branch=branch,
121
+ opener_arg=opener_arg,
122
+ path_arg=path_arg,
123
+ )
124
+ return action, worktree
125
+
126
+
127
+ class TestLaunch:
128
+ def test_shell_action_by_default(self, tmp_path: Path) -> None:
129
+ action, worktree = run_launch(Config(), tmp_path)
130
+ assert action is not None
131
+ variables = launch.launch_vars(
132
+ main=tmp_path / "api",
133
+ worktree=worktree,
134
+ worktrees_dir=worktree.parent,
135
+ branch="feat",
136
+ target=".",
137
+ )
138
+ assignments = " ".join(f"{k}={shlex.quote(v)}" for k, v in variables.items())
139
+ assert action.script == f"cd {worktree} && {assignments} stub-editor"
140
+ # WF_* is scoped to the command via prefix assignments, not exported.
141
+ assert "export" not in action.script
142
+ assert "WF_TITLE='api: feat'" in action.script
143
+
144
+ def test_path_arg_is_target_not_cwd(self, tmp_path: Path) -> None:
145
+ (tmp_path / "worktrees" / "api" / "feat" / "src").mkdir(parents=True)
146
+ action, worktree = run_launch(
147
+ Config(), tmp_path, opener_arg="tool {target}", path_arg="src"
148
+ )
149
+ assert action is not None
150
+ # cwd stays the worktree root; -p only sets the opener argument.
151
+ assert action.script.startswith(f"cd {worktree} && ")
152
+ assert action.script.endswith(" tool src")
153
+ assert "WF_TARGET=src" in action.script
154
+
155
+ def test_window_command_spawns_detached(self, tmp_path: Path, recorder: Recorder) -> None:
156
+ cfg = Config(window_command=f"{recorder.path} --title {{title}} $WF_COMMAND")
157
+ action, worktree = run_launch(cfg, tmp_path, opener_arg="the-opener")
158
+ assert action is None # nothing on stdout when a window is spawned
159
+ (line,) = recorder.wait_for_lines(1)
160
+ assert "--title" in line
161
+ assert "api: feat" in line
162
+ assert "the-opener" in line
163
+ assert f"cwd={worktree}" in line
164
+
165
+ def test_window_worktree_placeholder(self, tmp_path: Path, recorder: Recorder) -> None:
166
+ cfg = Config(window_command=f"{recorder.path} -d {{worktree}} $WF_COMMAND")
167
+ _, worktree = run_launch(cfg, tmp_path, opener_arg="x")
168
+ (line,) = recorder.wait_for_lines(1)
169
+ assert f"-d {worktree}" in line
170
+
171
+ def test_wf_command_splices_into_argv_words(self, tmp_path: Path, recorder: Recorder) -> None:
172
+ cfg = Config(window_command=f"{recorder.path} $WF_COMMAND")
173
+ run_launch(cfg, tmp_path, opener_arg="the-opener --flag")
174
+ (line,) = recorder.wait_for_lines(1)
175
+ assert "argv=the-opener --flag argc=2" in line
176
+
177
+ def test_command_placeholder_is_one_argument(self, tmp_path: Path, recorder: Recorder) -> None:
178
+ cfg = Config(window_command=f"{recorder.path} {{command}}")
179
+ run_launch(cfg, tmp_path, opener_arg="the-opener --flag")
180
+ (line,) = recorder.wait_for_lines(1)
181
+ assert "argv=the-opener --flag argc=1" in line
182
+
183
+ def test_window_process_inherits_wf_env(self, tmp_path: Path, recorder: Recorder) -> None:
184
+ cfg = Config(window_command=f"{recorder.path} $WF_COMMAND")
185
+ _, worktree = run_launch(cfg, tmp_path, opener_arg="x")
186
+ (line,) = recorder.wait_for_lines(1)
187
+ assert f"wf_worktree={worktree}" in line
188
+
189
+ def test_empty_window_command_after_expansion(
190
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
191
+ ) -> None:
192
+ monkeypatch.setenv("MY_TERM", "")
193
+ cfg = Config(window_command="$MY_TERM")
194
+ with pytest.raises(WorkforestError, match="empty"):
195
+ run_launch(cfg, tmp_path, opener_arg="x")
196
+
197
+ def test_missing_window_program(self, tmp_path: Path) -> None:
198
+ cfg = Config(window_command="no-such-terminal-xyz $WF_COMMAND")
199
+ with pytest.raises(WorkforestError, match="not found"):
200
+ run_launch(cfg, tmp_path, opener_arg="x")
201
+
202
+ def test_cd_action_quotes(self) -> None:
203
+ action = launch.cd_action(Path("/tmp/with space"))
204
+ assert action.script == "cd '/tmp/with space'"
@@ -151,7 +151,7 @@ class TestCompleteBackend:
151
151
 
152
152
  def test_scripts_and_openers(self, repo: Repo, run_cli: Run) -> None:
153
153
  repo.write_project_config(
154
- "scripts:\n migrate: 'true'\n test: 'true'\nopeners:\n edit: '$EDITOR {path}'\n"
154
+ "scripts:\n migrate: 'true'\n test: 'true'\nopeners:\n edit: '$EDITOR {target}'\n"
155
155
  )
156
156
  result = run_cli("--complete", "scripts", cwd=repo.path)
157
157
  assert result.out.splitlines() == ["migrate", "test"]
@@ -49,7 +49,7 @@ class TestPureHelpers:
49
49
  class TestCarousel:
50
50
  def test_config_openers_win(self, repo: Repo) -> None:
51
51
  ctx = commands.build_context(repo.path)
52
- ctx.config = Config(openers={"edit": "$EDITOR {path}", "git": "lazygit"})
52
+ ctx.config = Config(openers={"edit": "$EDITOR {target}", "git": "lazygit"})
53
53
  assert tui.opener_carousel(ctx) == [("edit", "edit"), ("git", "git")]
54
54
 
55
55
  def test_derived_fallback_pair(self, repo: Repo) -> None:
@@ -1,107 +0,0 @@
1
- """Opener/window template resolution and cd-protocol emission (DESIGN §3.4).
2
-
3
- No tool names appear here: what to run comes from config templates and the
4
- $VISUAL/$EDITOR contract; where to run it is the current shell (ShellAction)
5
- unless the user configured a window_command template.
6
- """
7
-
8
- import os
9
- import shlex
10
- import string
11
- import subprocess
12
- from dataclasses import dataclass
13
- from pathlib import Path
14
-
15
- from workforest.config import Config
16
- from workforest.errors import WorkforestError
17
-
18
-
19
- @dataclass(slots=True, frozen=True)
20
- class ShellAction:
21
- """A directive for the wf shell wrapper; the only thing cli.py prints
22
- to stdout for create/open/checkout."""
23
-
24
- script: str
25
-
26
-
27
- def cd_action(path: Path) -> ShellAction:
28
- return ShellAction(f"cd {shlex.quote(str(path))}")
29
-
30
-
31
- def resolve_opener_template(config: Config, opener_arg: str | None) -> str:
32
- """-o NAME → `openers` lookup, else verbatim; default $VISUAL → $EDITOR."""
33
- value = opener_arg or config.opener
34
- if value:
35
- return config.openers.get(value, value)
36
- for var in ("VISUAL", "EDITOR"):
37
- if os.environ.get(var):
38
- return os.environ[var]
39
- raise WorkforestError(
40
- "no opener: pass -o, set `opener` in a config file, or export $VISUAL/$EDITOR"
41
- )
42
-
43
-
44
- def resolve_target(worktree: Path, path_arg: str | None) -> tuple[Path, str]:
45
- """-p resolution: directories become cwd, files stay opener arguments."""
46
- if path_arg in (None, "", "."):
47
- return worktree, "."
48
- assert path_arg is not None
49
- target = worktree / path_arg
50
- if target.is_dir():
51
- return target, "."
52
- return worktree, path_arg
53
-
54
-
55
- def _expand_env(template: str) -> str:
56
- return string.Template(template).safe_substitute(os.environ)
57
-
58
-
59
- def build_opener_command(template: str, path_arg: str) -> str:
60
- expanded = _expand_env(template)
61
- if "{path}" in expanded:
62
- return expanded.replace("{path}", shlex.quote(path_arg))
63
- return expanded
64
-
65
-
66
- def launch(
67
- config: Config,
68
- *,
69
- worktree: Path,
70
- repo_name: str,
71
- opener_arg: str | None = None,
72
- path_arg: str | None = None,
73
- ) -> ShellAction | None:
74
- """Open a worktree: a ShellAction for the current shell, or a detached
75
- window spawn (returning None) when window_command is configured."""
76
- template = resolve_opener_template(config, opener_arg)
77
- cwd, resolved_path = resolve_target(worktree, path_arg)
78
- command = build_opener_command(template, resolved_path)
79
- if config.window_command:
80
- title = f"{repo_name}: {worktree.name}"
81
- spawn_window(config.window_command, title=title, cwd=cwd, command=command)
82
- return None
83
- return ShellAction(f"cd {shlex.quote(str(cwd))} && {command}")
84
-
85
-
86
- def spawn_window(window_template: str, *, title: str, cwd: Path, command: str) -> None:
87
- """Spawn the user's window_command fully detached (DESIGN §3.4)."""
88
- expanded = (
89
- _expand_env(window_template)
90
- .replace("{title}", shlex.quote(title))
91
- .replace("{path}", shlex.quote(str(cwd)))
92
- .replace("{command}", command)
93
- )
94
- argv = shlex.split(expanded)
95
- if not argv:
96
- raise WorkforestError("window_command expanded to an empty command")
97
- try:
98
- subprocess.Popen(
99
- argv,
100
- cwd=cwd,
101
- start_new_session=True,
102
- stdin=subprocess.DEVNULL,
103
- stdout=subprocess.DEVNULL,
104
- stderr=subprocess.DEVNULL,
105
- )
106
- except FileNotFoundError as exc:
107
- raise WorkforestError(f"window_command program not found: {argv[0]!r}") from exc
@@ -1,113 +0,0 @@
1
- """launch: opener/window template resolution and the cd protocol."""
2
-
3
- from pathlib import Path
4
-
5
- import pytest
6
-
7
- from workforest import launch
8
- from workforest.config import Config
9
- from workforest.errors import WorkforestError
10
-
11
- from .conftest import Recorder
12
-
13
-
14
- class TestOpenerResolution:
15
- def test_named_opener_from_config(self) -> None:
16
- cfg = Config(openers={"edit": "$EDITOR {path}"})
17
- assert launch.resolve_opener_template(cfg, "edit") == "$EDITOR {path}"
18
-
19
- def test_unknown_name_used_verbatim(self) -> None:
20
- cfg = Config(openers={"edit": "$EDITOR {path}"})
21
- assert launch.resolve_opener_template(cfg, "my-tool --flag") == "my-tool --flag"
22
-
23
- def test_config_default_opener(self) -> None:
24
- cfg = Config(opener="edit", openers={"edit": "$EDITOR {path}"})
25
- assert launch.resolve_opener_template(cfg, None) == "$EDITOR {path}"
26
-
27
- def test_visual_beats_editor(self, monkeypatch: pytest.MonkeyPatch) -> None:
28
- monkeypatch.setenv("VISUAL", "visual-tool")
29
- assert launch.resolve_opener_template(Config(), None) == "visual-tool"
30
-
31
- def test_editor_fallback(self) -> None:
32
- # EDITOR is pinned to stub-editor by the isolation fixture
33
- assert launch.resolve_opener_template(Config(), None) == "stub-editor"
34
-
35
- def test_no_opener_anywhere_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
36
- monkeypatch.delenv("EDITOR")
37
- with pytest.raises(WorkforestError, match="no opener"):
38
- launch.resolve_opener_template(Config(), None)
39
-
40
-
41
- class TestTargetResolution:
42
- def test_default_is_worktree_root(self, tmp_path: Path) -> None:
43
- assert launch.resolve_target(tmp_path, None) == (tmp_path, ".")
44
- assert launch.resolve_target(tmp_path, ".") == (tmp_path, ".")
45
-
46
- def test_directory_becomes_cwd(self, tmp_path: Path) -> None:
47
- (tmp_path / "src").mkdir()
48
- assert launch.resolve_target(tmp_path, "src") == (tmp_path / "src", ".")
49
-
50
- def test_file_stays_argument(self, tmp_path: Path) -> None:
51
- (tmp_path / "README.md").write_text("x")
52
- assert launch.resolve_target(tmp_path, "README.md") == (tmp_path, "README.md")
53
-
54
- def test_missing_path_stays_argument(self, tmp_path: Path) -> None:
55
- assert launch.resolve_target(tmp_path, "new-file.txt") == (tmp_path, "new-file.txt")
56
-
57
-
58
- class TestCommandBuilding:
59
- def test_path_placeholder_substituted_and_quoted(self) -> None:
60
- assert launch.build_opener_command("tool {path}", "a file.txt") == "tool 'a file.txt'"
61
-
62
- def test_env_expansion(self, monkeypatch: pytest.MonkeyPatch) -> None:
63
- monkeypatch.setenv("MYTOOL", "actual-tool")
64
- assert launch.build_opener_command("$MYTOOL {path}", ".") == "actual-tool ."
65
-
66
- def test_no_placeholder_left_alone(self) -> None:
67
- assert launch.build_opener_command("plain-tool --flag", ".") == "plain-tool --flag"
68
-
69
-
70
- class TestLaunch:
71
- def test_shell_action_by_default(self, tmp_path: Path) -> None:
72
- worktree = tmp_path / "feat"
73
- worktree.mkdir()
74
- action = launch.launch(Config(), worktree=worktree, repo_name="api")
75
- assert action is not None
76
- assert action.script == f"cd {worktree} && stub-editor"
77
-
78
- def test_window_command_spawns_detached(self, tmp_path: Path, recorder: Recorder) -> None:
79
- worktree = tmp_path / "feat"
80
- worktree.mkdir()
81
- cfg = Config(window_command=f"{recorder.path} --title {{title}} {{command}}")
82
- action = launch.launch(cfg, worktree=worktree, repo_name="api", opener_arg="the-opener")
83
- assert action is None # nothing on stdout when a window is spawned
84
- (line,) = recorder.wait_for_lines(1)
85
- assert "--title" in line
86
- assert "api: feat" in line
87
- assert "the-opener" in line
88
- assert f"cwd={worktree}" in line
89
-
90
- def test_window_path_placeholder(self, tmp_path: Path, recorder: Recorder) -> None:
91
- worktree = tmp_path / "feat"
92
- worktree.mkdir()
93
- cfg = Config(window_command=f"{recorder.path} -d {{path}} {{command}}")
94
- launch.launch(cfg, worktree=worktree, repo_name="api", opener_arg="x")
95
- (line,) = recorder.wait_for_lines(1)
96
- assert f"-d {worktree}" in line
97
-
98
- def test_empty_window_command_after_expansion(
99
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
100
- ) -> None:
101
- monkeypatch.setenv("MY_TERM", "")
102
- cfg = Config(window_command="$MY_TERM")
103
- with pytest.raises(WorkforestError, match="empty"):
104
- launch.launch(cfg, worktree=tmp_path, repo_name="api", opener_arg="x")
105
-
106
- def test_missing_window_program(self, tmp_path: Path) -> None:
107
- cfg = Config(window_command="no-such-terminal-xyz {command}")
108
- with pytest.raises(WorkforestError, match="not found"):
109
- launch.launch(cfg, worktree=tmp_path, repo_name="api", opener_arg="x")
110
-
111
- def test_cd_action_quotes(self) -> None:
112
- action = launch.cd_action(Path("/tmp/with space"))
113
- assert action.script == "cd '/tmp/with space'"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes