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.
Files changed (55) hide show
  1. workforest-0.3.0/.claude/rules/architecture.md +16 -0
  2. workforest-0.3.0/.claude/rules/conventions.md +12 -0
  3. workforest-0.3.0/.claude/rules/tests.md +17 -0
  4. {workforest-0.2.3 → workforest-0.3.0}/PKG-INFO +41 -16
  5. {workforest-0.2.3 → workforest-0.3.0}/README.md +40 -15
  6. workforest-0.3.0/completions/_workforest +44 -0
  7. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/__init__.py +1 -1
  8. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/cli.py +51 -34
  9. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/commands.py +76 -28
  10. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/completions.py +30 -10
  11. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/config.py +41 -29
  12. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/errors.py +1 -1
  13. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/examples/config.yaml +34 -27
  14. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/gitutil.py +26 -23
  15. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/hooks.py +5 -2
  16. workforest-0.3.0/src/workforest/integrations/__init__.py +2 -0
  17. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/integrations/claude.py +19 -8
  18. workforest-0.3.0/src/workforest/launch.py +235 -0
  19. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/output.py +22 -7
  20. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/shell/completion.bash +3 -1
  21. workforest-0.3.0/src/workforest/shell/completion.zsh +67 -0
  22. workforest-0.3.0/src/workforest/shell/workforest.sh +18 -0
  23. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/tui.py +26 -13
  24. {workforest-0.2.3 → workforest-0.3.0}/tests/conftest.py +11 -6
  25. {workforest-0.2.3 → workforest-0.3.0}/tests/test_claude.py +2 -2
  26. {workforest-0.2.3 → workforest-0.3.0}/tests/test_cli.py +6 -5
  27. {workforest-0.2.3 → workforest-0.3.0}/tests/test_commands.py +77 -4
  28. {workforest-0.2.3 → workforest-0.3.0}/tests/test_config.py +2 -2
  29. {workforest-0.2.3 → workforest-0.3.0}/tests/test_gitutil.py +20 -7
  30. {workforest-0.2.3 → workforest-0.3.0}/tests/test_launch.py +93 -61
  31. workforest-0.3.0/tests/test_output.py +56 -0
  32. {workforest-0.2.3 → workforest-0.3.0}/tests/test_shell.py +58 -6
  33. {workforest-0.2.3 → workforest-0.3.0}/tests/test_tui.py +10 -4
  34. workforest-0.2.3/completions/_workforest +0 -23
  35. workforest-0.2.3/src/workforest/integrations/__init__.py +0 -2
  36. workforest-0.2.3/src/workforest/launch.py +0 -204
  37. workforest-0.2.3/src/workforest/shell/completion.zsh +0 -38
  38. workforest-0.2.3/src/workforest/shell/workforest.sh +0 -16
  39. {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/ci.yml +0 -0
  40. {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/publish_aur.yml +0 -0
  41. {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/publish_homebrew.yml +0 -0
  42. {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/publish_pypi.yml +0 -0
  43. {workforest-0.2.3 → workforest-0.3.0}/.github/workflows/release.yml +0 -0
  44. {workforest-0.2.3 → workforest-0.3.0}/.gitignore +0 -0
  45. {workforest-0.2.3 → workforest-0.3.0}/LICENSE +0 -0
  46. {workforest-0.2.3 → workforest-0.3.0}/Makefile +0 -0
  47. {workforest-0.2.3 → workforest-0.3.0}/packaging/AUR/PKGBUILD.template +0 -0
  48. {workforest-0.2.3 → workforest-0.3.0}/packaging/homebrew/workforest.rb.template +0 -0
  49. {workforest-0.2.3 → workforest-0.3.0}/pyproject.toml +0 -0
  50. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/__main__.py +0 -0
  51. {workforest-0.2.3 → workforest-0.3.0}/src/workforest/shellinit.py +0 -0
  52. {workforest-0.2.3 → workforest-0.3.0}/tests/__init__.py +0 -0
  53. {workforest-0.2.3 → workforest-0.3.0}/tests/test_harness.py +0 -0
  54. {workforest-0.2.3 → workforest-0.3.0}/tests/test_hooks.py +0 -0
  55. {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.2.3
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 template, e.g. edit: "$EDITOR {target}"
112
- window_command: "" # "" → current shell; or e.g.
113
- # "kitty --title {title} --directory {worktree} $WF_COMMAND"
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 command templates sharing one variable
120
- family, which the launched process (and every script) also receives as
121
- environment variables:
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
- In templates, `$WF_X` (like any `$ENV` variable) inserts raw text that
134
- word-splits into multiple arguments, while `{x}` — `{worktree}`, `{target}`,
135
- `{title}`, … — inserts the shell-quoted value as exactly one argument.
136
- Openers run with the worktree root as working directory; in
137
- `window_command` the resolved opener command is additionally available as
138
- `$WF_COMMAND` (spliced into argv words) or `{command}` (one argument, for
139
- `$SHELL -c` wrappers). Spawned windows shed activation state inherited from
140
- the invoking shell (Python venv, conda, nvm, rvm) so the new session starts
141
- clean instead of carrying an environment it cannot deactivate.
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 template, e.g. edit: "$EDITOR {target}"
92
- window_command: "" # "" → current shell; or e.g.
93
- # "kitty --title {title} --directory {worktree} $WF_COMMAND"
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 command templates sharing one variable
100
- family, which the launched process (and every script) also receives as
101
- environment variables:
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
- In templates, `$WF_X` (like any `$ENV` variable) inserts raw text that
114
- word-splits into multiple arguments, while `{x}` — `{worktree}`, `{target}`,
115
- `{title}`, … — inserts the shell-quoted value as exactly one argument.
116
- Openers run with the worktree root as working directory; in
117
- `window_command` the resolved opener command is additionally available as
118
- `$WF_COMMAND` (spliced into argv words) or `{command}` (one argument, for
119
- `$SHELL -c` wrappers). Spawned windows shed activation state inherited from
120
- the invoking shell (Python venv, conda, nvm, rvm) so the new session starts
121
- clean instead of carrying an environment it cannot deactivate.
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,3 +1,3 @@
1
1
  """Workforest — git worktree forest management."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.3.0"
@@ -1,35 +1,44 @@
1
1
  """argparse front-end: shortcut dispatch, error → exit-code mapping, and the
2
- sole writer to stdout (DESIGN §3.3/§5)."""
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 EXIT_CANCELLED, EXIT_OK, WorkforestError
13
+ from workforest.errors import EXIT_OK, WorkforestError
13
14
  from workforest.launch import ShellAction
14
15
 
15
- SUBCOMMANDS = frozenset(
16
- {
17
- "create",
18
- "open",
19
- "list",
20
- "delete",
21
- "checkout",
22
- "run",
23
- "tui",
24
- "init",
25
- "config",
26
- "shell-init",
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 (DESIGN §3.7): the integration is invisible without
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 template")
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 {target}"
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 (or reuse) a worktree for a branch and open it")
148
- p.add_argument("branch", nargs="?", help="branch name (default: current branch)")
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 an existing worktree")
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 managed worktrees")
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 worktree(s)")
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="delete a worktree and check its branch out in main")
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 a named script from the merged config")
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="scaffold a .workforest.yaml project config")
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="show the merged configuration and its sources")
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="print the wf shell wrapper (eval in your shell rc)")
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="interactive mode (requires fzf)")
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="Claude Code integration")
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
- return EXIT_CANCELLED
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 (DESIGN §5)."""
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
- (DESIGN §3.5) — the only ones we list, complete, or delete."""
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 not name:
135
- raise UsageError("worktree name required")
136
- worktree = find_managed(ctx, name)
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 gitutil.status_porcelain(w.path) else 'clean'} {w.path}"
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
- for name in names:
202
- worktree = find_managed(ctx, name)
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 layer, path in sources)
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)