git-worktrees 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.github/workflows/publish.yml +13 -12
  2. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/AGENTS.md +5 -0
  3. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/CHANGELOG.md +23 -0
  4. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/PKG-INFO +6 -4
  5. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/README.md +5 -3
  6. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gw.fish +0 -1
  7. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwa.fish +0 -1
  8. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwh.fish +0 -1
  9. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwl.fish +0 -1
  10. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwm.fish +0 -1
  11. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwnb.fish +0 -1
  12. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwp.fish +0 -1
  13. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwr.fish +0 -1
  14. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gwrot.fish +0 -1
  15. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/completions/gws.fish +0 -1
  16. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/pyproject.toml +1 -1
  17. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/scripts/verify-publish.bash +36 -8
  18. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/cli.py +52 -9
  19. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_cli.py +31 -1
  20. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_completions.py +1 -1
  21. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_version.py +5 -5
  22. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/uv.lock +1 -1
  23. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gw +0 -1
  24. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwa +0 -1
  25. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwh +0 -1
  26. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwl +0 -1
  27. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwm +0 -1
  28. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwnb +0 -1
  29. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwp +0 -1
  30. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwr +0 -1
  31. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gwrot +0 -1
  32. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/completions/_gws +0 -1
  33. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.agents/skills/release/SKILL.md +0 -0
  34. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.claude/settings.json +0 -0
  35. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.github/workflows/tests.yml +0 -0
  36. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.gitignore +0 -0
  37. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.pre-commit-config.yaml +0 -0
  38. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/.python-version +0 -0
  39. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/LICENSE +0 -0
  40. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/conf.d/worktrees.fish +0 -0
  41. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/docs/DEVELOPMENT.md +0 -0
  42. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/functions/gwa.fish +0 -0
  43. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/functions/gwl.fish +0 -0
  44. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/functions/gwm.fish +0 -0
  45. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/functions/gwr.fish +0 -0
  46. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/renovate.json +0 -0
  47. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/__init__.py +0 -0
  48. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/forge.py +0 -0
  49. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/git.py +0 -0
  50. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/layout.py +0 -0
  51. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/merged.py +0 -0
  52. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/new_branch.py +0 -0
  53. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/pick.py +0 -0
  54. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/prune.py +0 -0
  55. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/render.py +0 -0
  56. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/repo.py +0 -0
  57. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/rotate.py +0 -0
  58. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/verdicts.py +0 -0
  59. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/src/worktrees/worktree.py +0 -0
  60. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/conftest.py +0 -0
  61. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_forge.py +0 -0
  62. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_gates.py +0 -0
  63. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_new_branch.py +0 -0
  64. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_render.py +0 -0
  65. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_rotate.py +0 -0
  66. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_shims.py +0 -0
  67. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_spec.py +0 -0
  68. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/tests/test_worktree.py +0 -0
  69. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/functions/gwa +0 -0
  70. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/functions/gwl +0 -0
  71. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/functions/gwm +0 -0
  72. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/functions/gwr +0 -0
  73. {git_worktrees-0.2.0 → git_worktrees-0.2.1}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -74,15 +74,19 @@ jobs:
74
74
  # release that should go ahead. The API needs no local history.
75
75
  - name: Check the commit is on main
76
76
  env:
77
- GH_TOKEN: ${{ github.token }}
78
- GH_REPO: ${{ github.repository }}
77
+ # GH_REPO carries the value the runner publishes as GITHUB_REPOSITORY: gh looks for
78
+ # GH_REPO and never at GITHUB_REPOSITORY. GITHUB_TOKEN is not a default variable at
79
+ # all, so the token has to be handed over rather than renamed.
80
+ GH_TOKEN: ${{ github.token }} # no runner default
81
+ GH_REPO: ${{ github.repository }} # = $GITHUB_REPOSITORY; gh reads GH_REPO
79
82
  run: rt github::assert_on_branch main
80
83
 
81
84
  - name: Wait for the tests on this commit
82
85
  env:
83
- GH_TOKEN: ${{ github.token }}
84
- GH_REPO: ${{ github.repository }}
85
- run: rt github::await_workflow "$GITHUB_SHA" tests.yml
86
+ GH_TOKEN: ${{ github.token }} # no runner default
87
+ GH_REPO: ${{ github.repository }} # = $GITHUB_REPOSITORY; gh reads GH_REPO
88
+ SHA: ${{ github.sha }} # = $GITHUB_SHA
89
+ run: rt github::await_workflow "$SHA" tests.yml
86
90
 
87
91
  - name: Install dependencies
88
92
  run: uv sync --all-extras --locked
@@ -168,13 +172,10 @@ jobs:
168
172
  - name: Publish
169
173
  run: uv publish --trusted-publishing always
170
174
 
171
- # An index serves what it has just accepted only after a delay, and a fixed
172
- # sleep either wastes time or fails a publish that worked. This backs off
173
- # across about 7.75 minutes and stops as soon as the version is there.
174
- - name: Wait for the PyPI index to catch up
175
- run: |
176
- rt net::await_url "https://pypi.org/pypi/$(uv version | tr ' ' '/')/json"
177
-
175
+ # An index serves what it has just accepted only after a delay, so the
176
+ # script retries the resolution rather than polling a different endpoint
177
+ # first: the JSON API answered for 0.2.0 while the simple index uv reads
178
+ # had not caught up, and the run went red on a publish that had worked.
178
179
  - name: Verify it installs from PyPI
179
180
  run: scripts/verify-publish.bash --prod
180
181
 
@@ -156,6 +156,11 @@ shorthands and whether the command changes the caller's directory. `gws`,
156
156
  so, and `gwh` prints the table rather than a second list that can disagree
157
157
  with it.
158
158
 
159
+ `version` is the one row that installs no console script, and `gwh` prints a
160
+ `—` in its first column to say so. A tool's version is asked through the
161
+ tool's name, so `gw version` and `gw v` are the two spellings and no parser
162
+ carries a `--version` flag.
163
+
159
164
  `_canonical()` raises at import when two rows answer to one name. A dict
160
165
  comprehension would keep the last one and leave a command reachable under a
161
166
  name that runs a different one.
@@ -12,6 +12,29 @@ not in here, whatever it cost to build.
12
12
  A release closes with a `### Choices` section when a decision in it is worth
13
13
  the reader's time: what was chosen, and what the alternative failed to do.
14
14
 
15
+ ## 0.2.1 - 2026-09-11
16
+
17
+ `--version` is gone from every command. `gw version`, or `gw v`, prints the
18
+ version this package installs; `gws --version` and the rest now fail as an
19
+ unrecognised argument, so a script reading the version back has one line to
20
+ change.
21
+
22
+ A name `gw` does not carry names the closest one that exists, instead of
23
+ printing a usage line and all twenty-two commands under an argparse error:
24
+
25
+ $ gw verison
26
+ gw: there is no command 'verison'
27
+ the closest is: gw version
28
+ gw help lists every command
29
+
30
+ ### Choices
31
+
32
+ `version` installs no binary of its own, and `gwh` prints a `—` in its
33
+ column to say so. A tenth console script, for a question asked once, would
34
+ be a name nobody types. Keeping `--version` beside it was the alternative:
35
+ two spellings of one answer, on every command, each to be completed,
36
+ documented and tested.
37
+
15
38
  ## 0.2.0 - 2026-09-11
16
39
 
17
40
  A squash-merged branch goes with its checkout, every removal names the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-worktrees
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Git worktree commands that refuse to lose work.
5
5
  Project-URL: Bug reports, https://github.com/MihaiBojin/worktrees/issues/new
6
6
  Project-URL: Source, https://github.com/MihaiBojin/worktrees
@@ -404,6 +404,7 @@ gwr [QUERY] gw remove (rm) remove one whose branch is finished
404
404
  gwnb NAME gw new-branch (nb, new) branch off the head branch (alpha)
405
405
  gwrot gw rotate (rot) the next branch in a series (alpha)
406
406
  gwh gw help (h) every command and alias, this list
407
+ — gw version (v) the version this package installs
407
408
  ```
408
409
 
409
410
  `gw help`, `gw h` and `gw` with no subcommand print the same thing. One table
@@ -412,9 +413,10 @@ cannot disagree, and it raises at import if two commands ever claim one name.
412
413
 
413
414
  ## Flags
414
415
 
415
- Every command takes `--version`. All but `gwh`, which prints a table and has
416
- nothing to be quiet about, also take `--json`, `-q`, `-v` and `--explain`.
417
- Beyond those:
416
+ `gw version`, or `gw v`, prints the version. No command takes a `--version`
417
+ flag. Every command but that one and `gwh`, which
418
+ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
419
+ `-v` and `--explain`. Beyond those:
418
420
 
419
421
  | | |
420
422
  | --- | --- |
@@ -379,6 +379,7 @@ gwr [QUERY] gw remove (rm) remove one whose branch is finished
379
379
  gwnb NAME gw new-branch (nb, new) branch off the head branch (alpha)
380
380
  gwrot gw rotate (rot) the next branch in a series (alpha)
381
381
  gwh gw help (h) every command and alias, this list
382
+ — gw version (v) the version this package installs
382
383
  ```
383
384
 
384
385
  `gw help`, `gw h` and `gw` with no subcommand print the same thing. One table
@@ -387,9 +388,10 @@ cannot disagree, and it raises at import if two commands ever claim one name.
387
388
 
388
389
  ## Flags
389
390
 
390
- Every command takes `--version`. All but `gwh`, which prints a table and has
391
- nothing to be quiet about, also take `--json`, `-q`, `-v` and `--explain`.
392
- Beyond those:
391
+ `gw version`, or `gw v`, prints the version. No command takes a `--version`
392
+ flag. Every command but that one and `gwh`, which
393
+ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
394
+ `-v` and `--explain`. Beyond those:
393
395
 
394
396
  | | |
395
397
  | --- | --- |
@@ -19,5 +19,4 @@ complete -c gw -n 'not __fish_use_subcommand' -s q -l quiet -d 'less output'
19
19
  complete -c gw -n 'not __fish_use_subcommand' -s v -l verbose -d 'print every git command'
20
20
  complete -c gw -n 'not __fish_use_subcommand' -l explain -d 'print every git command and exit'
21
21
 
22
- complete -c gw -l version -d "show program's version number and exit"
23
22
  complete -c gw -s h -l help -d 'show this help message and exit'
@@ -9,5 +9,4 @@ complete -c gwa -s q -l quiet -d 'the path alone'
9
9
  complete -c gwa -s v -l verbose -d 'print every git command'
10
10
  complete -c gwa -l explain -d 'print every git command and exit'
11
11
 
12
- complete -c gwa -l version -d 'show the version and exit'
13
12
  complete -c gwa -s h -l help -d 'show the help and exit'
@@ -2,5 +2,4 @@
2
2
 
3
3
  complete -c gwh -f
4
4
 
5
- complete -c gwh -l version -d 'show the version and exit'
6
5
  complete -c gwh -s h -l help -d 'show the help and exit'
@@ -11,5 +11,4 @@ complete -c gwl -s q -l quiet -d 'the path alone'
11
11
  complete -c gwl -s v -l verbose -d 'print every git command'
12
12
  complete -c gwl -l explain -d 'print every git command and exit'
13
13
 
14
- complete -c gwl -l version -d 'show the version and exit'
15
14
  complete -c gwl -s h -l help -d 'show the help and exit'
@@ -7,5 +7,4 @@ complete -c gwm -s q -l quiet -d 'the path alone'
7
7
  complete -c gwm -s v -l verbose -d 'print every git command'
8
8
  complete -c gwm -l explain -d 'print every git command and exit'
9
9
 
10
- complete -c gwm -l version -d 'show the version and exit'
11
10
  complete -c gwm -s h -l help -d 'show the help and exit'
@@ -9,5 +9,4 @@ complete -c gwnb -s q -l quiet -d 'say nothing on success'
9
9
  complete -c gwnb -s v -l verbose -d 'print every git command'
10
10
  complete -c gwnb -l explain -d 'print every git command and exit'
11
11
 
12
- complete -c gwnb -l version -d 'show the version and exit'
13
12
  complete -c gwnb -s h -l help -d 'show the help and exit'
@@ -13,5 +13,4 @@ complete -c gwp -s q -l quiet -d 'verdicts only'
13
13
  complete -c gwp -s v -l verbose -d 'print every git command'
14
14
  complete -c gwp -l explain -d 'print every git command and exit'
15
15
 
16
- complete -c gwp -l version -d 'show the version and exit'
17
16
  complete -c gwp -s h -l help -d 'show the help and exit'
@@ -14,5 +14,4 @@ complete -c gwr -s q -l quiet -d 'the path alone'
14
14
  complete -c gwr -s v -l verbose -d 'print every git command'
15
15
  complete -c gwr -l explain -d 'print every git command and exit'
16
16
 
17
- complete -c gwr -l version -d 'show the version and exit'
18
17
  complete -c gwr -s h -l help -d 'show the help and exit'
@@ -9,5 +9,4 @@ complete -c gwrot -s q -l quiet -d 'say nothing on success'
9
9
  complete -c gwrot -s v -l verbose -d 'print every git command'
10
10
  complete -c gwrot -l explain -d 'print every git command and exit'
11
11
 
12
- complete -c gwrot -l version -d 'show the version and exit'
13
12
  complete -c gwrot -s h -l help -d 'show the help and exit'
@@ -12,5 +12,4 @@ complete -c gws -s q -l quiet -d 'verdicts only'
12
12
  complete -c gws -s v -l verbose -d 'print every git command'
13
13
  complete -c gws -l explain -d 'print every git command and exit'
14
14
 
15
- complete -c gws -l version -d 'show the version and exit'
16
15
  complete -c gws -s h -l help -d 'show the help and exit'
@@ -9,7 +9,7 @@ authors = [
9
9
  ]
10
10
  # Static, and the single source of truth for the version. Bump it in a pull
11
11
  # request and run `uv lock`; the release tag only has to agree with it.
12
- version = "0.2.0"
12
+ version = "0.2.1"
13
13
  # The standard library answers every question this asks. A dependency here
14
14
  # would be paid on every invocation, and the whole reason this is Python is
15
15
  # that an invocation costs ~25 ms.
@@ -34,21 +34,49 @@ echo "Verifying $NAME==$VERSION from $WHICH..." >&2
34
34
  TMP="$(mktemp -d)"
35
35
  trap 'rm -rf "$TMP"' EXIT
36
36
 
37
- # The version the installed script reports, compared against the one asked
37
+ # An index accepts an upload before it serves it, and the delay differs between the JSON
38
+ # API and the simple index resolved against here: 0.2.0 answered on the former while this
39
+ # still could not find it. So the wait is this resolution retried rather than a different
40
+ # endpoint polled, because this is the condition a green publish is claiming.
41
+ #
42
+ # Six attempts backing off from 15s is about 7.75 minutes, and it stops the moment the
43
+ # version resolves.
44
+ readonly ATTEMPTS=6
45
+ readonly BASE=15
46
+
47
+ # The version the installed command reports, compared against the one asked
38
48
  # of the index. Running it and discarding the output would pass while every
39
49
  # command on the machine printed a number from a previous release.
40
- REPORTED="$(UV_CACHE_DIR="$TMP/cache" uv tool run \
41
- --isolated \
42
- --index "$INDEX" \
43
- --index-strategy unsafe-best-match \
44
- --from "$NAME==$VERSION" \
45
- gws --version)"
50
+ attempt=0
51
+ delay="$BASE"
52
+ while :; do
53
+ attempt=$((attempt + 1))
54
+
55
+ if REPORTED="$(UV_CACHE_DIR="$TMP/cache" uv tool run \
56
+ --isolated \
57
+ --index "$INDEX" \
58
+ --index-strategy unsafe-best-match \
59
+ --from "$NAME==$VERSION" \
60
+ gw version 2>"$TMP/resolve.err")"; then
61
+ break
62
+ fi
63
+
64
+ if [ "$attempt" -ge "$ATTEMPTS" ]; then
65
+ cat "$TMP/resolve.err" >&2
66
+ echo "$WHICH did not serve $NAME==$VERSION after $ATTEMPTS attempts." >&2
67
+ exit 1
68
+ fi
69
+
70
+ echo "waiting ${delay}s for $WHICH to serve $NAME==$VERSION (attempt $attempt/$ATTEMPTS)..." >&2
71
+ sleep "$delay"
72
+ delay=$((delay * 2))
73
+ done
46
74
  readonly REPORTED
47
75
 
48
76
  [ "$REPORTED" = "$VERSION" ] || {
49
77
  echo "Version mismatch after publishing to $WHICH." >&2
50
78
  echo " asked the index for: $VERSION" >&2
51
- echo " gws --version says: $REPORTED" >&2
79
+ echo " gw version says: $REPORTED" >&2
52
80
  exit 1
53
81
  }
54
82
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import difflib
6
7
  import json
7
8
  import sys
8
9
  from collections.abc import Callable
@@ -706,6 +707,19 @@ def run_remove(args: argparse.Namespace) -> int:
706
707
  # --------------------------------------------------------------------------
707
708
 
708
709
 
710
+ def run_version(args: argparse.Namespace) -> int:
711
+ """The version of the installed distribution."""
712
+ print(__version__)
713
+ return 0
714
+
715
+
716
+ def _listed(names: list[str]) -> str:
717
+ """`a, b and c`, and `a` on its own when there is one."""
718
+ if len(names) == 1:
719
+ return names[0]
720
+ return f"{', '.join(names[:-1])} and {names[-1]}"
721
+
722
+
709
723
  def run_help(args: argparse.Namespace) -> int:
710
724
  """Every name this package installs, and what each one answers."""
711
725
  print(f"worktrees {__version__}: git worktree commands that refuse to lose work")
@@ -718,7 +732,9 @@ def run_help(args: argparse.Namespace) -> int:
718
732
  spelled += " (" + ", ".join(c.aliases) + ")"
719
733
  rows.append(
720
734
  (
721
- Cell(f"{c.binary} {c.usage}".strip(), BOLD),
735
+ # A row installing no binary has nothing for this column:
736
+ # `gw version` is the only way to type it.
737
+ Cell(f"{c.binary} {c.usage}".strip() or "\u2014", BOLD),
722
738
  Cell(spelled),
723
739
  Cell(c.about),
724
740
  )
@@ -728,7 +744,7 @@ def run_help(args: argparse.Namespace) -> int:
728
744
 
729
745
  lands = [c.binary for c in _COMMANDS.values() if c.lands]
730
746
  print(
731
- f"{', '.join(lands[:-1])} and {lands[-1]} change the directory you are "
747
+ f"{_listed(lands)} change the directory you are "
732
748
  "standing in.\nA binary cannot do that, so each needs the shell function "
733
749
  "of the same name\nfrom the plugin; the rest need nothing but $PATH."
734
750
  )
@@ -736,8 +752,10 @@ def run_help(args: argparse.Namespace) -> int:
736
752
  # Which commands take the common flags is the table's answer, not a second
737
753
  # list here: a row with no flags takes none, and saying so in prose is how
738
754
  # a help text comes to promise a flag the program refuses.
739
- bare = [c.binary for c in _COMMANDS.values() if c.flags is None]
740
- scope = f"every command but {', '.join(bare)}" if bare else "every command"
755
+ bare = [
756
+ c.binary or f"gw {name}" for name, c in _COMMANDS.items() if c.flags is None
757
+ ]
758
+ scope = f"every command but {_listed(bare)}" if bare else "every command"
741
759
  print(
742
760
  "gwnb and gwrot start branches rather than worktrees. Both are alpha "
743
761
  f"and may go.\n\n--json, -q, -v and --explain work on {scope}, and\n"
@@ -785,7 +803,7 @@ class _Command:
785
803
  run: Callable[[argparse.Namespace], int]
786
804
  flags: Callable[[argparse.ArgumentParser], None] | None
787
805
  about: str
788
- binary: str
806
+ binary: str = "" # the console script, where the command installs one
789
807
  usage: str = ""
790
808
  aliases: tuple[str, ...] = ()
791
809
  lands: bool = False # changes the caller's directory, so it needs a shim
@@ -864,6 +882,15 @@ _COMMANDS: dict[str, _Command] = {
864
882
  "gwh",
865
883
  aliases=("h",),
866
884
  ),
885
+ # The one row with no console script. Asking a tool its version is done
886
+ # through the tool's name, and a tenth binary for it would be a name
887
+ # nobody types.
888
+ "version": _Command(
889
+ run_version,
890
+ None,
891
+ "the version this package installs",
892
+ aliases=("v",),
893
+ ),
867
894
  }
868
895
 
869
896
 
@@ -902,9 +929,7 @@ _ROTATE_HELP = (
902
929
 
903
930
 
904
931
  def _parser(prog: str, description: str) -> argparse.ArgumentParser:
905
- p = argparse.ArgumentParser(prog=prog, description=description)
906
- p.add_argument("--version", action="version", version=__version__)
907
- return p
932
+ return argparse.ArgumentParser(prog=prog, description=description)
908
933
 
909
934
 
910
935
  def gws(argv: list[str] | None = None) -> int:
@@ -974,6 +999,23 @@ def gw(argv: list[str] | None = None) -> int:
974
999
  return main(argv, prog="gw")
975
1000
 
976
1001
 
1002
+ def _unknown_command(prog: str, typed: str) -> int:
1003
+ """A name no row answers to, and the nearest one that does.
1004
+
1005
+ argparse spells every command twice for this, once in a usage line and
1006
+ once in the choices, so the name worth reading arrives last. The pool is
1007
+ the same table `gwh` prints, plus `--help`, which people type as a
1008
+ command.
1009
+ """
1010
+ _err(f"{prog}: there is no command {typed!r}")
1011
+ pool = [*_CANONICAL, "--help"]
1012
+ near = difflib.get_close_matches(typed, pool, n=1)
1013
+ if near:
1014
+ _err(f"the closest is: {prog} {near[0]}")
1015
+ _err(f"{prog} help lists every command")
1016
+ return 2
1017
+
1018
+
977
1019
  def main(argv: list[str] | None = None, prog: str = "worktrees") -> int:
978
1020
  """The CLI a shim and an agent call."""
979
1021
  p = _parser(prog, "Git worktree commands that refuse to lose work.")
@@ -992,11 +1034,12 @@ def main(argv: list[str] | None = None, prog: str = "worktrees") -> int:
992
1034
  not in (
993
1035
  "-h",
994
1036
  "--help",
995
- "--version",
996
1037
  )
997
1038
  ):
998
1039
  # `worktrees --explain` is not about one command; status answers it.
999
1040
  args_in = ["status", *args_in]
1041
+ if args_in and not args_in[0].startswith("-") and args_in[0] not in _CANONICAL:
1042
+ return _unknown_command(prog, args_in[0])
1000
1043
  return _dispatch(p, args_in, None)
1001
1044
 
1002
1045
 
@@ -326,6 +326,13 @@ def test_prune_prints_the_reason_rather_than_naming_the_command(world) -> None:
326
326
  assert "gws" not in p.stdout
327
327
 
328
328
 
329
+ def test_version_is_a_command_and_not_a_flag(world) -> None:
330
+ for entry in ("gws", "gw", "main"):
331
+ p = run(world, entry, "--version")
332
+ assert p.returncode == 2
333
+ assert "unrecognized arguments: --version" in p.stderr
334
+
335
+
329
336
  def test_dry_run_is_refused_by_name(world) -> None:
330
337
  for entry in ("gws", "gwp"):
331
338
  p = run(world, entry, "--dry-run")
@@ -397,6 +404,27 @@ def test_a_bare_flag_means_status(world) -> None:
397
404
  assert "git worktree list --porcelain -z" in p.stdout
398
405
 
399
406
 
407
+ def test_an_unknown_command_names_the_closest_one(world) -> None:
408
+ """argparse answers this with a usage line and twenty-two choices, and
409
+ the name worth reading arrives last."""
410
+ p = run(world, "gw", "verison")
411
+ assert p.returncode == 2
412
+ assert p.stderr.splitlines() == [
413
+ "gw: there is no command 'verison'",
414
+ "the closest is: gw version",
415
+ "gw help lists every command",
416
+ ]
417
+
418
+
419
+ def test_an_unknown_command_near_nothing_still_points_at_help(world) -> None:
420
+ p = run(world, "main", "xyzzy")
421
+ assert p.returncode == 2
422
+ assert p.stderr.splitlines() == [
423
+ "worktrees: there is no command 'xyzzy'",
424
+ "worktrees help lists every command",
425
+ ]
426
+
427
+
400
428
  # --------------------------------------------------------------------------
401
429
  # shorthands, and the table they come from
402
430
  # --------------------------------------------------------------------------
@@ -450,10 +478,12 @@ def test_every_command_is_installed_under_its_own_name() -> None:
450
478
 
451
479
  root = Path(__file__).resolve().parents[1]
452
480
  scripts = tomllib.loads((root / "pyproject.toml").read_text())["project"]["scripts"]
453
- declared = {c.binary for c in cli._COMMANDS.values()}
481
+ declared = {c.binary for c in cli._COMMANDS.values() if c.binary}
454
482
  assert declared <= set(scripts)
455
483
  # The two that take a subcommand are the only ones with no row of their own.
456
484
  assert set(scripts) - declared == {"gw", "worktrees"}
485
+ # and version is the only row that installs nothing.
486
+ assert [n for n, c in cli._COMMANDS.items() if not c.binary] == ["version"]
457
487
 
458
488
 
459
489
  # --------------------------------------------------------------------------
@@ -36,7 +36,7 @@ SRC = str(ROOT / "src")
36
36
  FISH = ROOT / "completions"
37
37
  ZSH = ROOT / "zsh" / "plugins" / "worktrees" / "completions"
38
38
 
39
- BINARIES = sorted(c.binary for c in _COMMANDS.values())
39
+ BINARIES = sorted(c.binary for c in _COMMANDS.values() if c.binary)
40
40
 
41
41
 
42
42
  def _help(entry: str) -> str:
@@ -1,4 +1,4 @@
1
- """The version is written once, and `--version` reads that one."""
1
+ """The version is written once, and `gw version` reads that one."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -36,18 +36,18 @@ def test_version_matches_the_distribution() -> None:
36
36
  assert __version__ == declared
37
37
 
38
38
 
39
- def test_every_console_script_reports_it() -> None:
40
- """Driven, because `--version` goes through argparse rather than a return."""
39
+ def test_both_names_that_take_a_subcommand_report_it() -> None:
40
+ """Driven, because the console script is what a person runs."""
41
41
  declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
42
42
  "version"
43
43
  ]
44
- for entry in ("gws", "gwp", "gwnb", "main"):
44
+ for entry in ("gw", "main"):
45
45
  proc = subprocess.run(
46
46
  [
47
47
  sys.executable,
48
48
  "-c",
49
49
  f"from worktrees.cli import {entry}; raise SystemExit({entry}())",
50
- "--version",
50
+ "version",
51
51
  ],
52
52
  capture_output=True,
53
53
  text=True,
@@ -40,7 +40,7 @@ wheels = [
40
40
 
41
41
  [[package]]
42
42
  name = "git-worktrees"
43
- version = "0.2.0"
43
+ version = "0.2.1"
44
44
  source = { editable = "." }
45
45
 
46
46
  [package.optional-dependencies]
@@ -13,7 +13,6 @@ _gw() {
13
13
  local curcontext="$curcontext" state
14
14
 
15
15
  _arguments -C \
16
- '--version[show the version and exit]' \
17
16
  '(-h --help)'{-h,--help}'[show the help and exit]' \
18
17
  '1: :->command' \
19
18
  '*:: :->rest'
@@ -8,5 +8,4 @@ _arguments -s \
8
8
  '(-q --quiet)'{-q,--quiet}'[the path alone]' \
9
9
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
10
10
  '--explain[print every git command and exit]' \
11
- '--version[show the version and exit]' \
12
11
  '(-h --help)'{-h,--help}'[show the help and exit]'
@@ -3,5 +3,4 @@
3
3
  # Flags as `gwh --help` lists them.
4
4
 
5
5
  _arguments -s \
6
- '--version[show the version and exit]' \
7
6
  '(-h --help)'{-h,--help}'[show the help and exit]'
@@ -21,6 +21,5 @@ _arguments -s \
21
21
  '(-q --quiet)'{-q,--quiet}'[the path alone]' \
22
22
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
23
23
  '--explain[print every git command and exit]' \
24
- '--version[show the version and exit]' \
25
24
  '(-h --help)'{-h,--help}'[show the help and exit]' \
26
25
  '1:worktree:__worktrees_names'
@@ -7,5 +7,4 @@ _arguments -s \
7
7
  '(-q --quiet)'{-q,--quiet}'[the path alone]' \
8
8
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
9
9
  '--explain[print every git command and exit]' \
10
- '--version[show the version and exit]' \
11
10
  '(-h --help)'{-h,--help}'[show the help and exit]'
@@ -8,5 +8,4 @@ _arguments -s \
8
8
  '(-q --quiet)'{-q,--quiet}'[say nothing on success]' \
9
9
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
10
10
  '--explain[print every git command and exit]' \
11
- '--version[show the version and exit]' \
12
11
  '(-h --help)'{-h,--help}'[show the help and exit]'
@@ -12,5 +12,4 @@ _arguments -s \
12
12
  '(-q --quiet)'{-q,--quiet}'[verdicts only]' \
13
13
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
14
14
  '--explain[print every git command and exit]' \
15
- '--version[show the version and exit]' \
16
15
  '(-h --help)'{-h,--help}'[show the help and exit]'
@@ -24,6 +24,5 @@ _arguments -s \
24
24
  '(-q --quiet)'{-q,--quiet}'[the path alone]' \
25
25
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
26
26
  '--explain[print every git command and exit]' \
27
- '--version[show the version and exit]' \
28
27
  '(-h --help)'{-h,--help}'[show the help and exit]' \
29
28
  '1:worktree:__gwr_targets'
@@ -8,5 +8,4 @@ _arguments -s \
8
8
  '(-q --quiet)'{-q,--quiet}'[say nothing on success]' \
9
9
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
10
10
  '--explain[print every git command and exit]' \
11
- '--version[show the version and exit]' \
12
11
  '(-h --help)'{-h,--help}'[show the help and exit]'
@@ -11,5 +11,4 @@ _arguments -s \
11
11
  '(-q --quiet)'{-q,--quiet}'[verdicts only]' \
12
12
  '(-v --verbose)'{-v,--verbose}'[print every git command]' \
13
13
  '--explain[print every git command and exit]' \
14
- '--version[show the version and exit]' \
15
14
  '(-h --help)'{-h,--help}'[show the help and exit]'
File without changes
File without changes