workforest 0.2.3__tar.gz → 0.3.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.3.0/.claude/rules/architecture.md +16 -0
- workforest-0.3.0/.claude/rules/conventions.md +12 -0
- workforest-0.3.0/.claude/rules/tests.md +17 -0
- {workforest-0.2.3 → workforest-0.3.0}/PKG-INFO +41 -16
- {workforest-0.2.3 → workforest-0.3.0}/README.md +40 -15
- workforest-0.3.0/completions/_workforest +44 -0
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/__init__.py +1 -1
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/cli.py +51 -34
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/commands.py +76 -28
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/completions.py +30 -10
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/config.py +41 -29
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/errors.py +1 -1
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/examples/config.yaml +34 -27
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/gitutil.py +26 -23
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/hooks.py +5 -2
- workforest-0.3.0/src/workforest/integrations/__init__.py +2 -0
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/integrations/claude.py +19 -8
- workforest-0.3.0/src/workforest/launch.py +235 -0
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/output.py +22 -7
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/shell/completion.bash +3 -1
- workforest-0.3.0/src/workforest/shell/completion.zsh +67 -0
- workforest-0.3.0/src/workforest/shell/workforest.sh +18 -0
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/tui.py +26 -13
- {workforest-0.2.3 → workforest-0.3.0}/tests/conftest.py +11 -6
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_claude.py +2 -2
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_cli.py +6 -5
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_commands.py +77 -4
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_config.py +2 -2
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_gitutil.py +20 -7
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_launch.py +93 -61
- workforest-0.3.0/tests/test_output.py +56 -0
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_shell.py +58 -6
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_tui.py +10 -4
- workforest-0.2.3/completions/_workforest +0 -23
- workforest-0.2.3/src/workforest/integrations/__init__.py +0 -2
- workforest-0.2.3/src/workforest/launch.py +0 -204
- workforest-0.2.3/src/workforest/shell/completion.zsh +0 -38
- workforest-0.2.3/src/workforest/shell/workforest.sh +0 -16
- {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/ci.yml +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/publish_aur.yml +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/publish_homebrew.yml +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/publish_pypi.yml +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/release.yml +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/.gitignore +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/LICENSE +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/Makefile +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/packaging/AUR/PKGBUILD.template +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/packaging/homebrew/workforest.rb.template +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/pyproject.toml +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/__main__.py +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/src/workforest/shellinit.py +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/tests/__init__.py +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_harness.py +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/tests/test_hooks.py +0 -0
- {workforest-0.2.3 → workforest-0.3.0}/uv.lock +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Architecture invariants
|
|
2
|
+
|
|
3
|
+
- `gitutil.py` is the only module that spawns git. Consumers get typed
|
|
4
|
+
results; worktree data comes from `--porcelain -z` output, never from
|
|
5
|
+
parsing the human-readable form.
|
|
6
|
+
- `cli.py` is the sole stdout writer. Commands return
|
|
7
|
+
`ShellAction | str | None`; stdout carries the shell-wrapper cd protocol,
|
|
8
|
+
so nothing else may print there (hook/script stdout is diverted to stderr).
|
|
9
|
+
- `tui.py`: everything except the fzf subprocess is pure and unit-tested;
|
|
10
|
+
fzf is the only sanctioned external tool there.
|
|
11
|
+
- `completions.py` must never break the shell: any error yields an empty
|
|
12
|
+
candidate list, and output stays plain `NAME<TAB>ANNOTATION` lines.
|
|
13
|
+
- `integrations/claude.py` is experimental: it reads Claude Code's private
|
|
14
|
+
on-disk state. Session lines are rewritten by JSON parsing, never by
|
|
15
|
+
string substitution.
|
|
16
|
+
- README.md is the project reference; there is no separate design document.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Code conventions
|
|
2
|
+
|
|
3
|
+
- Python 3.14, uv-managed. Verify changes with `make check` (ruff lint +
|
|
4
|
+
format check, mypy, pytest with a 90% coverage floor). Run
|
|
5
|
+
`uv run ruff format .` rather than hand-formatting.
|
|
6
|
+
- When a return value or constant bundles fields whose positions carry
|
|
7
|
+
meaning, use a small named dataclass — `@dataclass(slots=True,
|
|
8
|
+
frozen=True)` — not an anonymous tuple or nested dict. Plain dicts are for
|
|
9
|
+
genuine key→value lookups only (env maps, branch→remotes).
|
|
10
|
+
- Pre-1.0 with zero users: on breaking changes keep the clean design and
|
|
11
|
+
state what to re-run (e.g. re-eval shell-init). Never add
|
|
12
|
+
backward-compatibility shims.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "tests/**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Test conventions
|
|
7
|
+
|
|
8
|
+
- Isolation contract (see `tests/conftest.py`): no test may read or write
|
|
9
|
+
the real environment. The autouse `isolated_env` fixture redirects HOME,
|
|
10
|
+
XDG_CONFIG_HOME, and git config, and pins SHELL=/bin/sh, EDITOR, and
|
|
11
|
+
NO_COLOR — rely on it instead of patching these per test.
|
|
12
|
+
- Build repos through the `repo` / `make_repo` fixtures and the `Repo`
|
|
13
|
+
helper methods (`add_branch`, `add_remote`, `write_project_config`,
|
|
14
|
+
`make_dirty`) rather than raw git calls.
|
|
15
|
+
- Coverage floor is 90% (`--cov-fail-under=90` in pyproject.toml); pure
|
|
16
|
+
helpers are expected to be unit-tested, subprocess/terminal glue may be
|
|
17
|
+
`# pragma: no cover`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: workforest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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>
|
|
@@ -20,6 +20,14 @@ Description-Content-Type: text/markdown
|
|
|
20
20
|
|
|
21
21
|
# Workforest
|
|
22
22
|
|
|
23
|
+
## Elevator pitch
|
|
24
|
+
|
|
25
|
+
Your repo has one working directory; your AI agents want five. Workforest
|
|
26
|
+
gives every feature — and every agent — its own disposable git worktree, so
|
|
27
|
+
parallel work on the same repo never collides.
|
|
28
|
+
|
|
29
|
+
## About
|
|
30
|
+
|
|
23
31
|
Git worktree forest management: one main checkout plus any number of
|
|
24
32
|
disposable, per-branch worktrees in a predictable location — created, set up,
|
|
25
33
|
opened, and cleaned up with one command.
|
|
@@ -108,17 +116,17 @@ All keys, with defaults:
|
|
|
108
116
|
```yaml
|
|
109
117
|
worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME" # where the forest lives
|
|
110
118
|
opener: "" # default opener; "" → $VISUAL → $EDITOR
|
|
111
|
-
openers: {} # name -> command
|
|
112
|
-
window_command: "" # "" → current shell; or e.g.
|
|
113
|
-
# "
|
|
119
|
+
openers: {} # name -> shell command, e.g. edit: '$EDITOR "$WF_TARGET"'
|
|
120
|
+
window_command: "" # "" → current shell; or e.g. kitty --title "$WF_TITLE"
|
|
121
|
+
# --directory "$WF_WORKTREE" $SHELL -c "$WF_COMMAND"
|
|
114
122
|
symlinks: [] # untracked assets linked from main into new worktrees
|
|
115
123
|
setup_scripts: [] # shell snippets run in a fresh worktree
|
|
116
124
|
scripts: {} # name -> snippet for `wf run NAME`
|
|
117
125
|
```
|
|
118
126
|
|
|
119
|
-
Openers and `window_command` are
|
|
120
|
-
family
|
|
121
|
-
|
|
127
|
+
Openers and `window_command` are plain shell commands, run via `$SHELL -c`
|
|
128
|
+
with one variable family in the environment — the same family the launched
|
|
129
|
+
process and every script receive:
|
|
122
130
|
|
|
123
131
|
| Variable | Value |
|
|
124
132
|
|---|---|
|
|
@@ -130,15 +138,17 @@ environment variables:
|
|
|
130
138
|
| `WF_TARGET` | the `-p` argument, default `.` (launch-only) |
|
|
131
139
|
| `WF_TITLE` | window label, `project_name: feat-x` (launch-only) |
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
Standard shell rules apply — there is no workforest template syntax:
|
|
142
|
+
`"$WF_X"` is exactly one argument, bare `$WF_X` word-splits, and `$$`,
|
|
143
|
+
braces, pipes, and `&&` mean whatever your shell says they mean (a
|
|
144
|
+
misspelled `$WF_VAR` expands to empty, as in any shell). Openers run with
|
|
145
|
+
the worktree root as working directory. In `window_command` the resolved
|
|
146
|
+
opener command is additionally available as `$WF_COMMAND` — still
|
|
147
|
+
unexpanded, so run it through a shell of its own for its `$WF_*` references
|
|
148
|
+
to resolve: `$SHELL -c "$WF_COMMAND"`. Spawned windows shed activation
|
|
149
|
+
state inherited from the invoking shell (Python venv, conda, nvm, rvm) so
|
|
150
|
+
the new session starts clean instead of carrying an environment it cannot
|
|
151
|
+
deactivate.
|
|
142
152
|
|
|
143
153
|
Fully commented reference configs:
|
|
144
154
|
[`config.yaml`](src/workforest/examples/config.yaml) (user/system) and
|
|
@@ -187,8 +197,23 @@ workforest tui [MODE]
|
|
|
187
197
|
workforest init [--local]
|
|
188
198
|
workforest config [--json]
|
|
189
199
|
workforest shell-init [bash|zsh]
|
|
200
|
+
workforest claude copy-session SESSION_ID # experimental
|
|
190
201
|
```
|
|
191
202
|
|
|
203
|
+
`open` (and the opener shortcut, e.g. `wf edit`) without NAME opens the
|
|
204
|
+
worktree you are standing in.
|
|
205
|
+
|
|
206
|
+
`create` resolves BRANCH in order: existing local branch, then a branch on
|
|
207
|
+
exactly one remote (checked out tracking it), then a brand-new branch.
|
|
208
|
+
`REMOTE/BRANCH` picks the remote explicitly — needed when several remotes
|
|
209
|
+
carry the same branch name; if that local name is already taken, `create`
|
|
210
|
+
prompts for a different one.
|
|
211
|
+
|
|
212
|
+
`workforest claude` (shown only when `~/.claude` exists) copies a Claude
|
|
213
|
+
Code session from the main worktree into the current one. It is
|
|
214
|
+
**experimental**: it manipulates Claude Code's private on-disk state,
|
|
215
|
+
which is not a stable interface, so any Claude Code update may break it.
|
|
216
|
+
|
|
192
217
|
Exit codes: `0` ok · `1` error · `2` usage · `3` cancelled · `4` config error.
|
|
193
218
|
Human messages go to stderr; stdout carries only machine output (`cd`
|
|
194
219
|
directives for the `wf` wrapper, `--porcelain` listings, dumps).
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Workforest
|
|
2
2
|
|
|
3
|
+
## Elevator pitch
|
|
4
|
+
|
|
5
|
+
Your repo has one working directory; your AI agents want five. Workforest
|
|
6
|
+
gives every feature — and every agent — its own disposable git worktree, so
|
|
7
|
+
parallel work on the same repo never collides.
|
|
8
|
+
|
|
9
|
+
## About
|
|
10
|
+
|
|
3
11
|
Git worktree forest management: one main checkout plus any number of
|
|
4
12
|
disposable, per-branch worktrees in a predictable location — created, set up,
|
|
5
13
|
opened, and cleaned up with one command.
|
|
@@ -88,17 +96,17 @@ All keys, with defaults:
|
|
|
88
96
|
```yaml
|
|
89
97
|
worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME" # where the forest lives
|
|
90
98
|
opener: "" # default opener; "" → $VISUAL → $EDITOR
|
|
91
|
-
openers: {} # name -> command
|
|
92
|
-
window_command: "" # "" → current shell; or e.g.
|
|
93
|
-
# "
|
|
99
|
+
openers: {} # name -> shell command, e.g. edit: '$EDITOR "$WF_TARGET"'
|
|
100
|
+
window_command: "" # "" → current shell; or e.g. kitty --title "$WF_TITLE"
|
|
101
|
+
# --directory "$WF_WORKTREE" $SHELL -c "$WF_COMMAND"
|
|
94
102
|
symlinks: [] # untracked assets linked from main into new worktrees
|
|
95
103
|
setup_scripts: [] # shell snippets run in a fresh worktree
|
|
96
104
|
scripts: {} # name -> snippet for `wf run NAME`
|
|
97
105
|
```
|
|
98
106
|
|
|
99
|
-
Openers and `window_command` are
|
|
100
|
-
family
|
|
101
|
-
|
|
107
|
+
Openers and `window_command` are plain shell commands, run via `$SHELL -c`
|
|
108
|
+
with one variable family in the environment — the same family the launched
|
|
109
|
+
process and every script receive:
|
|
102
110
|
|
|
103
111
|
| Variable | Value |
|
|
104
112
|
|---|---|
|
|
@@ -110,15 +118,17 @@ environment variables:
|
|
|
110
118
|
| `WF_TARGET` | the `-p` argument, default `.` (launch-only) |
|
|
111
119
|
| `WF_TITLE` | window label, `project_name: feat-x` (launch-only) |
|
|
112
120
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
Standard shell rules apply — there is no workforest template syntax:
|
|
122
|
+
`"$WF_X"` is exactly one argument, bare `$WF_X` word-splits, and `$$`,
|
|
123
|
+
braces, pipes, and `&&` mean whatever your shell says they mean (a
|
|
124
|
+
misspelled `$WF_VAR` expands to empty, as in any shell). Openers run with
|
|
125
|
+
the worktree root as working directory. In `window_command` the resolved
|
|
126
|
+
opener command is additionally available as `$WF_COMMAND` — still
|
|
127
|
+
unexpanded, so run it through a shell of its own for its `$WF_*` references
|
|
128
|
+
to resolve: `$SHELL -c "$WF_COMMAND"`. Spawned windows shed activation
|
|
129
|
+
state inherited from the invoking shell (Python venv, conda, nvm, rvm) so
|
|
130
|
+
the new session starts clean instead of carrying an environment it cannot
|
|
131
|
+
deactivate.
|
|
122
132
|
|
|
123
133
|
Fully commented reference configs:
|
|
124
134
|
[`config.yaml`](src/workforest/examples/config.yaml) (user/system) and
|
|
@@ -167,8 +177,23 @@ workforest tui [MODE]
|
|
|
167
177
|
workforest init [--local]
|
|
168
178
|
workforest config [--json]
|
|
169
179
|
workforest shell-init [bash|zsh]
|
|
180
|
+
workforest claude copy-session SESSION_ID # experimental
|
|
170
181
|
```
|
|
171
182
|
|
|
183
|
+
`open` (and the opener shortcut, e.g. `wf edit`) without NAME opens the
|
|
184
|
+
worktree you are standing in.
|
|
185
|
+
|
|
186
|
+
`create` resolves BRANCH in order: existing local branch, then a branch on
|
|
187
|
+
exactly one remote (checked out tracking it), then a brand-new branch.
|
|
188
|
+
`REMOTE/BRANCH` picks the remote explicitly — needed when several remotes
|
|
189
|
+
carry the same branch name; if that local name is already taken, `create`
|
|
190
|
+
prompts for a different one.
|
|
191
|
+
|
|
192
|
+
`workforest claude` (shown only when `~/.claude` exists) copies a Claude
|
|
193
|
+
Code session from the main worktree into the current one. It is
|
|
194
|
+
**experimental**: it manipulates Claude Code's private on-disk state,
|
|
195
|
+
which is not a stable interface, so any Claude Code update may break it.
|
|
196
|
+
|
|
172
197
|
Exit codes: `0` ok · `1` error · `2` usage · `3` cancelled · `4` config error.
|
|
173
198
|
Human messages go to stderr; stdout carries only machine output (`cd`
|
|
174
199
|
directives for the `wf` wrapper, `--porcelain` listings, dumps).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#compdef workforest wf
|
|
2
|
+
# zsh completion for workforest / wf — installed to zsh site-functions.
|
|
3
|
+
# Dynamic candidates are delegated to `workforest --complete TOPIC`.
|
|
4
|
+
|
|
5
|
+
local topic cmd line name rest kind desc
|
|
6
|
+
local -a items cmds
|
|
7
|
+
cmd="${words[2]:-}"
|
|
8
|
+
if (( CURRENT == 2 )); then
|
|
9
|
+
topic=commands
|
|
10
|
+
else
|
|
11
|
+
case "$cmd" in
|
|
12
|
+
create) topic=branches ;;
|
|
13
|
+
open|delete|checkout) topic=worktrees ;;
|
|
14
|
+
run) topic=scripts ;;
|
|
15
|
+
claude) topic=claude-sessions ;;
|
|
16
|
+
tui|list|init|config|shell-init) topic=none ;;
|
|
17
|
+
*) topic=worktrees ;;
|
|
18
|
+
esac
|
|
19
|
+
fi
|
|
20
|
+
if [[ "$topic" != none ]]; then
|
|
21
|
+
items=(${(f)"$(workforest --complete "$topic" 2>/dev/null)"})
|
|
22
|
+
if [[ "$topic" == commands ]]; then
|
|
23
|
+
# NAME<TAB>KIND<TAB>DESCRIPTION → described candidates; one group
|
|
24
|
+
# (kind spelled out in the description) so list order, alignment,
|
|
25
|
+
# and the command/opener distinction survive fzf-tab's merging.
|
|
26
|
+
# Cyan for openers, but only under fzf-tab (fzf renders ANSI with
|
|
27
|
+
# --ansi; plain complist would print the escapes literally).
|
|
28
|
+
local pre="" post=""
|
|
29
|
+
if (( ${+functions[fzf-tab-complete]} )); then
|
|
30
|
+
pre=$'\e[36m' post=$'\e[0m'
|
|
31
|
+
fi
|
|
32
|
+
for line in "${items[@]}"; do
|
|
33
|
+
name="${line%%$'\t'*}"
|
|
34
|
+
rest="${line#*$'\t'}"
|
|
35
|
+
kind="${rest%%$'\t'*}"
|
|
36
|
+
desc="${rest#*$'\t'}"
|
|
37
|
+
[[ "$kind" == opener ]] && desc="${pre}opener: ${desc}${post}"
|
|
38
|
+
cmds+=("${name//:/\\:}:${desc}")
|
|
39
|
+
done
|
|
40
|
+
(( ${#cmds} )) && _describe -t commands 'workforest command' cmds
|
|
41
|
+
else
|
|
42
|
+
(( ${#items} )) && compadd -a items
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
@@ -1,35 +1,44 @@
|
|
|
1
1
|
"""argparse front-end: shortcut dispatch, error → exit-code mapping, and the
|
|
2
|
-
sole writer to stdout
|
|
2
|
+
sole writer to stdout."""
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
5
|
import os
|
|
6
|
+
import signal
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
from workforest import __version__, commands, output
|
|
11
12
|
from workforest.commands import CommandResult
|
|
12
|
-
from workforest.errors import
|
|
13
|
+
from workforest.errors import EXIT_OK, WorkforestError
|
|
13
14
|
from workforest.launch import ShellAction
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
16
|
+
# Single source for subcommand names and their one-line help: build_parser()
|
|
17
|
+
# and the `commands` completion topic both read from here.
|
|
18
|
+
SUBCOMMAND_HELP: dict[str, str] = {
|
|
19
|
+
"create": "create (or reuse) a worktree for a branch and open it",
|
|
20
|
+
"open": "open an existing worktree",
|
|
21
|
+
"list": "list managed worktrees",
|
|
22
|
+
"delete": "delete worktree(s)",
|
|
23
|
+
"checkout": "delete a worktree and check its branch out in main",
|
|
24
|
+
"run": "run a named script from the merged config",
|
|
25
|
+
"tui": "interactive mode (requires fzf)",
|
|
26
|
+
"init": "scaffold a .workforest.yaml project config",
|
|
27
|
+
"config": "show the merged configuration and its sources",
|
|
28
|
+
"shell-init": "print the wf shell wrapper (eval in your shell rc)",
|
|
29
|
+
"claude": "Claude Code integration (experimental: may break on any Claude Code update)",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
SUBCOMMANDS = frozenset(SUBCOMMAND_HELP) - {"claude"} # claude is feature-gated
|
|
33
|
+
|
|
34
|
+
# Marks a stdout line as a directive for the wf shell wrapper to eval. The
|
|
35
|
+
# unit-separator control byte cannot appear in data output (listings, dumps),
|
|
36
|
+
# so the wrapper never mistakes data for something to execute.
|
|
37
|
+
SHELL_DIRECTIVE_PREFIX = "\x1f"
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
def _claude_available() -> bool:
|
|
32
|
-
"""Feature gate
|
|
41
|
+
"""Feature gate: the integration is invisible without
|
|
33
42
|
~/.claude. Filesystem check only — core never imports the integration."""
|
|
34
43
|
return (Path.home() / ".claude").is_dir()
|
|
35
44
|
|
|
@@ -43,7 +52,7 @@ def _known_subcommands() -> frozenset[str]:
|
|
|
43
52
|
def _emit(result: CommandResult) -> None:
|
|
44
53
|
match result:
|
|
45
54
|
case ShellAction(script=script):
|
|
46
|
-
print(script)
|
|
55
|
+
print(f"{SHELL_DIRECTIVE_PREFIX}{script}")
|
|
47
56
|
case str() as text if text:
|
|
48
57
|
print(text)
|
|
49
58
|
case _:
|
|
@@ -139,28 +148,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
139
148
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
149
|
|
|
141
150
|
def opener_args(p: argparse.ArgumentParser) -> None:
|
|
142
|
-
p.add_argument("-o", "--opener", help="opener name or command
|
|
151
|
+
p.add_argument("-o", "--opener", help="opener name or shell command")
|
|
143
152
|
p.add_argument(
|
|
144
|
-
"-p", "--path", help="path inside the worktree, passed to the opener as
|
|
153
|
+
"-p", "--path", help="path inside the worktree, passed to the opener as $WF_TARGET"
|
|
145
154
|
)
|
|
146
155
|
|
|
147
|
-
p = sub.add_parser("create", help="create
|
|
148
|
-
p.add_argument(
|
|
156
|
+
p = sub.add_parser("create", help=SUBCOMMAND_HELP["create"])
|
|
157
|
+
p.add_argument(
|
|
158
|
+
"branch", nargs="?", help="branch name or REMOTE/BRANCH (default: current branch)"
|
|
159
|
+
)
|
|
149
160
|
opener_args(p)
|
|
150
161
|
p.add_argument("--no-hooks", action="store_true", help="skip symlinks and setup scripts")
|
|
151
162
|
p.add_argument("--no-open", action="store_true", help="create only, do not open")
|
|
152
163
|
p.set_defaults(func=_handle_create)
|
|
153
164
|
|
|
154
|
-
p = sub.add_parser("open", help="open
|
|
165
|
+
p = sub.add_parser("open", help=SUBCOMMAND_HELP["open"])
|
|
155
166
|
p.add_argument("name", nargs="?", help="worktree directory name")
|
|
156
167
|
opener_args(p)
|
|
157
168
|
p.set_defaults(func=_handle_open)
|
|
158
169
|
|
|
159
|
-
p = sub.add_parser("list", help="list
|
|
170
|
+
p = sub.add_parser("list", help=SUBCOMMAND_HELP["list"])
|
|
160
171
|
p.add_argument("--porcelain", action="store_true", help="stable tab-separated output")
|
|
161
172
|
p.set_defaults(func=_handle_list)
|
|
162
173
|
|
|
163
|
-
p = sub.add_parser("delete", help="delete
|
|
174
|
+
p = sub.add_parser("delete", help=SUBCOMMAND_HELP["delete"])
|
|
164
175
|
p.add_argument("names", nargs="+", metavar="NAME")
|
|
165
176
|
p.add_argument("--force", action="store_true", help="skip the dirty-worktree confirmation")
|
|
166
177
|
group = p.add_mutually_exclusive_group()
|
|
@@ -168,12 +179,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
168
179
|
group.add_argument("--keep-branch", action="store_true", help="never delete the branch")
|
|
169
180
|
p.set_defaults(func=_handle_delete)
|
|
170
181
|
|
|
171
|
-
p = sub.add_parser("checkout", help="
|
|
182
|
+
p = sub.add_parser("checkout", help=SUBCOMMAND_HELP["checkout"])
|
|
172
183
|
p.add_argument("name", metavar="NAME")
|
|
173
184
|
p.add_argument("--force", action="store_true", help="skip the dirty-worktree confirmation")
|
|
174
185
|
p.set_defaults(func=_handle_checkout)
|
|
175
186
|
|
|
176
|
-
p = sub.add_parser("run", help="run
|
|
187
|
+
p = sub.add_parser("run", help=SUBCOMMAND_HELP["run"])
|
|
177
188
|
p.add_argument("script", metavar="SCRIPT")
|
|
178
189
|
p.add_argument(
|
|
179
190
|
"args",
|
|
@@ -183,7 +194,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
183
194
|
)
|
|
184
195
|
p.set_defaults(func=_handle_run)
|
|
185
196
|
|
|
186
|
-
p = sub.add_parser("init", help="
|
|
197
|
+
p = sub.add_parser("init", help=SUBCOMMAND_HELP["init"])
|
|
187
198
|
p.add_argument(
|
|
188
199
|
"--local",
|
|
189
200
|
action="store_true",
|
|
@@ -191,20 +202,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
191
202
|
)
|
|
192
203
|
p.set_defaults(func=_handle_init)
|
|
193
204
|
|
|
194
|
-
p = sub.add_parser("config", help="
|
|
205
|
+
p = sub.add_parser("config", help=SUBCOMMAND_HELP["config"])
|
|
195
206
|
p.add_argument("--json", action="store_true")
|
|
196
207
|
p.set_defaults(func=_handle_config)
|
|
197
208
|
|
|
198
|
-
p = sub.add_parser("shell-init", help="
|
|
209
|
+
p = sub.add_parser("shell-init", help=SUBCOMMAND_HELP["shell-init"])
|
|
199
210
|
p.add_argument("shell", nargs="?", choices=("bash", "zsh"), help="default: from $SHELL")
|
|
200
211
|
p.set_defaults(func=_handle_shell_init)
|
|
201
212
|
|
|
202
|
-
p = sub.add_parser("tui", help="
|
|
213
|
+
p = sub.add_parser("tui", help=SUBCOMMAND_HELP["tui"])
|
|
203
214
|
p.add_argument("mode", nargs="?", help="initial mode (create/open/checkout/delete)")
|
|
204
215
|
p.set_defaults(func=_handle_tui)
|
|
205
216
|
|
|
206
217
|
if _claude_available():
|
|
207
|
-
p = sub.add_parser("claude", help="
|
|
218
|
+
p = sub.add_parser("claude", help=SUBCOMMAND_HELP["claude"])
|
|
208
219
|
claude_sub = p.add_subparsers(dest="claude_command", required=True)
|
|
209
220
|
cp = claude_sub.add_parser(
|
|
210
221
|
"copy-session", help="copy a session from the main worktree into this one"
|
|
@@ -251,6 +262,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
251
262
|
raise
|
|
252
263
|
output.error(str(exc))
|
|
253
264
|
return exc.exit_code
|
|
254
|
-
except KeyboardInterrupt:
|
|
265
|
+
except KeyboardInterrupt: # pragma: no cover - terminates the process
|
|
266
|
+
# Die by SIGINT instead of exiting normally: a parent shell decides
|
|
267
|
+
# whether to abort a loop by how the child died (WIFSIGNALED), not by
|
|
268
|
+
# its exit code. Explicit prompt cancels still exit EXIT_CANCELLED.
|
|
255
269
|
output.info("")
|
|
256
|
-
|
|
270
|
+
sys.stderr.flush()
|
|
271
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
272
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
273
|
+
return 128 + signal.SIGINT # not reached
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Core commands. Each returns a ShellAction (stdout directive), a str
|
|
2
|
-
(stdout text), or None — cli.py is the sole stdout writer
|
|
2
|
+
(stdout text), or None — cli.py is the sole stdout writer."""
|
|
3
3
|
|
|
4
4
|
import importlib.resources
|
|
5
5
|
import json
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -42,8 +43,8 @@ def build_context(cwd: Path | None = None) -> Context:
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
def managed_worktrees(ctx: Context) -> list[gitutil.Worktree]:
|
|
45
|
-
"""Worktrees located directly inside the resolved worktrees dir
|
|
46
|
-
|
|
46
|
+
"""Worktrees located directly inside the resolved worktrees dir —
|
|
47
|
+
the only ones we list, complete, or delete."""
|
|
47
48
|
return [
|
|
48
49
|
worktree
|
|
49
50
|
for worktree in gitutil.list_worktrees(ctx.main)
|
|
@@ -62,6 +63,47 @@ def short_branch_name(branch: str) -> str:
|
|
|
62
63
|
return branch.rsplit("/", 1)[-1]
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
@dataclass(slots=True, frozen=True)
|
|
67
|
+
class ResolvedBranch:
|
|
68
|
+
branch: str # local branch name
|
|
69
|
+
track: str | None # remote ref the branch should track, if any
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_branch(ctx: Context, spec: str) -> ResolvedBranch:
|
|
73
|
+
"""Resolve BRANCH or REMOTE/BRANCH to a local branch and its remote ref.
|
|
74
|
+
|
|
75
|
+
Precedence: exact local branch, explicit REMOTE/BRANCH, a branch known to
|
|
76
|
+
exactly one remote; anything else names a new local branch.
|
|
77
|
+
"""
|
|
78
|
+
if gitutil.branch_exists(spec, ctx.main):
|
|
79
|
+
return ResolvedBranch(spec, track=None)
|
|
80
|
+
remote_map = gitutil.remote_branches(ctx.main)
|
|
81
|
+
for remote in sorted(gitutil.remotes(ctx.main), key=len, reverse=True):
|
|
82
|
+
branch = spec.removeprefix(f"{remote}/")
|
|
83
|
+
if branch == spec:
|
|
84
|
+
continue
|
|
85
|
+
if remote not in remote_map.get(branch, ()):
|
|
86
|
+
raise WorkforestError(f"branch {branch!r} not found on remote {remote!r}")
|
|
87
|
+
while gitutil.branch_exists(branch, ctx.main):
|
|
88
|
+
# The obvious local name is taken (possibly tracking a different
|
|
89
|
+
# remote), so the new branch needs a name of its own.
|
|
90
|
+
if not output.interactive():
|
|
91
|
+
raise WorkforestError(
|
|
92
|
+
f"branch {branch!r} already exists locally; `wf create {branch}` to use it"
|
|
93
|
+
)
|
|
94
|
+
branch = output.ask(f"branch {branch!r} already exists locally; local name for {spec}:")
|
|
95
|
+
if not branch:
|
|
96
|
+
raise CancelledError("cancelled")
|
|
97
|
+
return ResolvedBranch(branch, track=spec)
|
|
98
|
+
carriers = remote_map.get(spec, [])
|
|
99
|
+
if len(carriers) > 1:
|
|
100
|
+
raise WorkforestError(
|
|
101
|
+
f"branch {spec!r} exists on multiple remotes ({', '.join(carriers)}); "
|
|
102
|
+
f"pick one, e.g. `wf create {carriers[0]}/{spec}`"
|
|
103
|
+
)
|
|
104
|
+
return ResolvedBranch(spec, track=f"{carriers[0]}/{spec}" if carriers else None)
|
|
105
|
+
|
|
106
|
+
|
|
65
107
|
def _script_env(ctx: Context, worktree: Path, branch: str | None) -> dict[str, str]:
|
|
66
108
|
return hooks.script_env(
|
|
67
109
|
main=ctx.main,
|
|
@@ -84,6 +126,8 @@ def cmd_create(
|
|
|
84
126
|
branch = gitutil.current_branch(ctx.cwd_root)
|
|
85
127
|
if branch == "HEAD":
|
|
86
128
|
raise WorkforestError("detached HEAD: specify a branch name")
|
|
129
|
+
resolved = _resolve_branch(ctx, branch)
|
|
130
|
+
branch = resolved.branch
|
|
87
131
|
|
|
88
132
|
existing = gitutil.find_branch_worktree(branch, ctx.main)
|
|
89
133
|
if existing is not None:
|
|
@@ -102,7 +146,7 @@ def cmd_create(
|
|
|
102
146
|
if worktree_path.exists():
|
|
103
147
|
raise WorkforestError(f"directory exists but is not a worktree: {worktree_path}")
|
|
104
148
|
ctx.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
-
gitutil.worktree_add(ctx.main, worktree_path, branch)
|
|
149
|
+
gitutil.worktree_add(ctx.main, worktree_path, branch, track=resolved.track)
|
|
106
150
|
output.success(f"created worktree for {branch!r} at {worktree_path}")
|
|
107
151
|
if not no_hooks:
|
|
108
152
|
env = _script_env(ctx, worktree_path, branch)
|
|
@@ -131,9 +175,14 @@ def cmd_open(
|
|
|
131
175
|
opener: str | None = None,
|
|
132
176
|
path_arg: str | None = None,
|
|
133
177
|
) -> CommandResult:
|
|
134
|
-
if
|
|
135
|
-
|
|
136
|
-
|
|
178
|
+
if name:
|
|
179
|
+
worktree = find_managed(ctx, name)
|
|
180
|
+
else:
|
|
181
|
+
# No name, but standing in a managed worktree: that's the one.
|
|
182
|
+
current = next((w for w in managed_worktrees(ctx) if w.path == ctx.cwd_root), None)
|
|
183
|
+
if current is None:
|
|
184
|
+
raise UsageError("worktree name required (or run inside a managed worktree)")
|
|
185
|
+
worktree = current
|
|
137
186
|
return launch.launch(
|
|
138
187
|
ctx.config,
|
|
139
188
|
main=ctx.main,
|
|
@@ -147,28 +196,25 @@ def cmd_open(
|
|
|
147
196
|
|
|
148
197
|
def cmd_list(ctx: Context, *, porcelain: bool = False) -> CommandResult:
|
|
149
198
|
worktrees = managed_worktrees(ctx)
|
|
150
|
-
if porcelain:
|
|
151
|
-
lines = [
|
|
152
|
-
"\t".join(
|
|
153
|
-
(
|
|
154
|
-
w.name,
|
|
155
|
-
w.branch or "",
|
|
156
|
-
str(w.path),
|
|
157
|
-
"1" if gitutil.status_porcelain(w.path) else "0",
|
|
158
|
-
)
|
|
159
|
-
)
|
|
160
|
-
for w in worktrees
|
|
161
|
-
]
|
|
162
|
-
return "\n".join(lines)
|
|
163
199
|
if not worktrees:
|
|
200
|
+
if porcelain:
|
|
201
|
+
return ""
|
|
164
202
|
output.info(f"no worktrees in {ctx.worktrees_dir} (create one with: wf create BRANCH)")
|
|
165
203
|
return None
|
|
204
|
+
# One `git status` per worktree; subprocess-bound, so run them together.
|
|
205
|
+
with ThreadPoolExecutor(max_workers=min(8, len(worktrees))) as pool:
|
|
206
|
+
dirty = list(pool.map(lambda w: bool(gitutil.status_porcelain(w.path)), worktrees))
|
|
207
|
+
if porcelain:
|
|
208
|
+
return "\n".join(
|
|
209
|
+
"\t".join((w.name, w.branch or "", str(w.path), "1" if is_dirty else "0"))
|
|
210
|
+
for w, is_dirty in zip(worktrees, dirty, strict=True)
|
|
211
|
+
)
|
|
166
212
|
name_width = max(len(w.name) for w in worktrees)
|
|
167
213
|
branch_width = max(len(w.branch or "(detached)") for w in worktrees)
|
|
168
214
|
rows = [
|
|
169
215
|
f"{w.name:<{name_width}} {w.branch or '(detached)':<{branch_width}} "
|
|
170
|
-
f"{'dirty' if
|
|
171
|
-
for w in worktrees
|
|
216
|
+
f"{'dirty' if is_dirty else 'clean'} {w.path}"
|
|
217
|
+
for w, is_dirty in zip(worktrees, dirty, strict=True)
|
|
172
218
|
]
|
|
173
219
|
return "\n".join(rows)
|
|
174
220
|
|
|
@@ -198,12 +244,14 @@ def cmd_delete(
|
|
|
198
244
|
delete_branch: bool | None = None,
|
|
199
245
|
) -> CommandResult:
|
|
200
246
|
result: CommandResult = None
|
|
201
|
-
|
|
202
|
-
|
|
247
|
+
# Resolve every name first: a typo must fail the batch before anything
|
|
248
|
+
# is deleted, not strand it half-done.
|
|
249
|
+
worktrees = [find_managed(ctx, name) for name in names]
|
|
250
|
+
for worktree in worktrees:
|
|
203
251
|
_confirm_dirty(worktree, "Delete anyway?", force=force)
|
|
204
252
|
branch = worktree.branch
|
|
205
253
|
gitutil.worktree_remove(ctx.main, worktree.path, force=True)
|
|
206
|
-
output.success(f"deleted worktree {name!r}")
|
|
254
|
+
output.success(f"deleted worktree {worktree.name!r}")
|
|
207
255
|
if worktree.path == ctx.cwd_root:
|
|
208
256
|
# The shell is standing in the directory we just removed —
|
|
209
257
|
# move it back to the main checkout.
|
|
@@ -276,13 +324,13 @@ def cmd_config_show(*, as_json: bool = False) -> CommandResult:
|
|
|
276
324
|
config = ctx.config
|
|
277
325
|
except NotARepoError:
|
|
278
326
|
config = load_config(None)
|
|
279
|
-
sources = [(layer, str(path)) for layer, path in config.sources]
|
|
280
327
|
if as_json:
|
|
328
|
+
sources = [{"layer": s.layer, "path": str(s.path)} for s in config.sources]
|
|
281
329
|
return json.dumps({"config": config.as_dict(), "sources": sources}, indent=2)
|
|
282
330
|
dump = yaml.safe_dump(config.as_dict(), sort_keys=False).rstrip("\n")
|
|
283
331
|
lines = [dump, "", "# sources (low -> high):"]
|
|
284
|
-
if sources:
|
|
285
|
-
lines.extend(f"# {layer}: {path}" for
|
|
332
|
+
if config.sources:
|
|
333
|
+
lines.extend(f"# {s.layer}: {s.path}" for s in config.sources)
|
|
286
334
|
else:
|
|
287
335
|
lines.append("# (built-in defaults only)")
|
|
288
336
|
return "\n".join(lines)
|