workforest 0.1.2__tar.gz → 0.2.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.
- {workforest-0.1.2 → workforest-0.2.0}/.github/workflows/publish_aur.yml +3 -0
- {workforest-0.1.2 → workforest-0.2.0}/PKG-INFO +25 -7
- {workforest-0.1.2 → workforest-0.2.0}/README.md +23 -5
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/__init__.py +1 -1
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/cli.py +3 -1
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/commands.py +6 -2
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/examples/config.yaml +42 -18
- workforest-0.2.0/src/workforest/launch.py +162 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/conftest.py +3 -1
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_cli.py +5 -4
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_commands.py +15 -8
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_config.py +2 -2
- workforest-0.2.0/tests/test_launch.py +204 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_shell.py +1 -1
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_tui.py +1 -1
- workforest-0.1.2/src/workforest/launch.py +0 -107
- workforest-0.1.2/tests/test_launch.py +0 -113
- {workforest-0.1.2 → workforest-0.2.0}/.github/workflows/ci.yml +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/.github/workflows/publish_pypi.yml +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/.github/workflows/release.yml +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/.gitignore +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/LICENSE +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/Makefile +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/completions/_workforest +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/packaging/AUR/.SRCINFO +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/packaging/AUR/PKGBUILD +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/pyproject.toml +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/__main__.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/completions.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/config.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/errors.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/gitutil.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/hooks.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/integrations/__init__.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/integrations/claude.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/output.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/shell/completion.bash +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/shell/completion.zsh +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/shell/workforest.sh +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/shellinit.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/src/workforest/tui.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/__init__.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_claude.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_gitutil.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_harness.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/tests/test_hooks.py +0 -0
- {workforest-0.1.2 → workforest-0.2.0}/uv.lock +0 -0
|
@@ -72,6 +72,9 @@ jobs:
|
|
|
72
72
|
run: |
|
|
73
73
|
version="${GITHUB_REF_NAME#v}"
|
|
74
74
|
cp packaging/AUR/PKGBUILD packaging/AUR/.SRCINFO /tmp/
|
|
75
|
+
# The workspace is created by the host runner under a different UID
|
|
76
|
+
# than the container's root, so git rejects it without this.
|
|
77
|
+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
|
75
78
|
# The workflow runs detached at the release tag; move to main to commit.
|
|
76
79
|
# -f discards the in-place edits we just copied to /tmp; build
|
|
77
80
|
# artifacts in packaging/AUR are untracked and never git-added.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: workforest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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 {
|
|
106
|
+
openers: {} # name -> command template, e.g. edit: "$EDITOR {target}"
|
|
107
107
|
window_command: "" # "" → current shell; or e.g.
|
|
108
|
-
# "kitty --title {title} --directory {
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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 {
|
|
87
|
+
openers: {} # name -> command template, e.g. edit: "$EDITOR {target}"
|
|
88
88
|
window_command: "" # "" → current shell; or e.g.
|
|
89
|
-
# "kitty --title {title} --directory {
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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 {
|
|
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 {
|
|
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
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
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 {
|
|
92
|
-
# window_command: "foot --title={title} --working-directory={
|
|
93
|
-
# window_command: "alacritty --title {title} --working-directory {
|
|
94
|
-
# window_command: "tmux new-window -c {
|
|
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(
|
|
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("
|
|
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("
|
|
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 {
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
126
|
+
assert action.script.startswith(f"cd {ctx.worktrees_dir / 'feat'} && WF_MAIN=")
|
|
127
|
+
assert action.script.endswith(" stub-editor")
|
|
124
128
|
|
|
125
|
-
def
|
|
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
|
|
136
|
+
assert action.script.startswith(f"cd {ctx.worktrees_dir / 'feat'} && ")
|
|
137
|
+
assert "WF_TARGET=src" in action.script
|
|
132
138
|
|
|
133
|
-
def
|
|
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 {
|
|
142
|
+
action = commands.cmd_open(ctx, "feat", opener="tool {target}", path_arg="README.md")
|
|
137
143
|
assert isinstance(action, ShellAction)
|
|
138
|
-
assert action.script
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|