git-worktrees 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 (85) hide show
  1. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.agents/skills/release/SKILL.md +16 -6
  2. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.github/workflows/publish.yml +20 -41
  3. git_worktrees-0.3.0/.github/workflows/testpypi.yml +69 -0
  4. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.github/workflows/tests.yml +5 -0
  5. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/AGENTS.md +19 -2
  6. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/CHANGELOG.md +49 -3
  7. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/PKG-INFO +14 -10
  8. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/README.md +13 -9
  9. git_worktrees-0.3.0/docs/BENCHMARK.md +88 -0
  10. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/docs/DEVELOPMENT.md +80 -25
  11. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/functions/gwa.fish +6 -1
  12. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/functions/gwl.fish +6 -1
  13. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/functions/gwm.fish +6 -1
  14. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/functions/gwr.fish +6 -1
  15. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/pyproject.toml +1 -1
  16. git_worktrees-0.3.0/scripts/README.md +76 -0
  17. git_worktrees-0.3.0/scripts/benchmark.py +363 -0
  18. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/cli.py +127 -28
  19. git_worktrees-0.3.0/src/worktrees/errors.py +16 -0
  20. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/forge.py +9 -3
  21. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/git.py +51 -14
  22. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/new_branch.py +7 -2
  23. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/pick.py +11 -3
  24. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/render.py +7 -3
  25. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/repo.py +23 -4
  26. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/rotate.py +14 -3
  27. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/verdicts.py +20 -4
  28. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/worktree.py +7 -2
  29. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/conftest.py +44 -0
  30. git_worktrees-0.3.0/tests/test_benchmark.py +136 -0
  31. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_cli.py +24 -16
  32. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_completions.py +6 -19
  33. git_worktrees-0.3.0/tests/test_fixture.py +69 -0
  34. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_forge.py +2 -9
  35. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_gates.py +0 -2
  36. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_new_branch.py +3 -15
  37. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_rotate.py +2 -14
  38. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_shims.py +30 -0
  39. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_worktree.py +18 -16
  40. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/uv.lock +1 -1
  41. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwa +14 -0
  42. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwl +14 -0
  43. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwm +14 -0
  44. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwr +14 -0
  45. git_worktrees-0.2.3/zsh/plugins/worktrees/functions/gwa +0 -9
  46. git_worktrees-0.2.3/zsh/plugins/worktrees/functions/gwl +0 -9
  47. git_worktrees-0.2.3/zsh/plugins/worktrees/functions/gwm +0 -9
  48. git_worktrees-0.2.3/zsh/plugins/worktrees/functions/gwr +0 -9
  49. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.claude/settings.json +0 -0
  50. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.gitignore +0 -0
  51. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.pre-commit-config.yaml +0 -0
  52. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/.python-version +0 -0
  53. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/LICENSE +0 -0
  54. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gw.fish +0 -0
  55. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwa.fish +0 -0
  56. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwh.fish +0 -0
  57. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwl.fish +0 -0
  58. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwm.fish +0 -0
  59. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwnb.fish +0 -0
  60. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwp.fish +0 -0
  61. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwr.fish +0 -0
  62. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gwrot.fish +0 -0
  63. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/completions/gws.fish +0 -0
  64. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/conf.d/worktrees.fish +0 -0
  65. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/hatch_build.py +0 -0
  66. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/renovate.json +0 -0
  67. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/scripts/verify-publish.bash +0 -0
  68. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/__init__.py +0 -0
  69. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/layout.py +0 -0
  70. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/merged.py +0 -0
  71. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/src/worktrees/prune.py +0 -0
  72. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_render.py +0 -0
  73. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_spec.py +0 -0
  74. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/tests/test_version.py +0 -0
  75. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gw +0 -0
  76. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwa +0 -0
  77. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwh +0 -0
  78. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwl +0 -0
  79. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwm +0 -0
  80. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwnb +0 -0
  81. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwp +0 -0
  82. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwr +0 -0
  83. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwrot +0 -0
  84. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gws +0 -0
  85. {git_worktrees-0.2.3 → git_worktrees-0.3.0}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -43,7 +43,7 @@ rather than passing it.
43
43
  git switch --create release/v<version> --no-track origin/main
44
44
  ```
45
45
 
46
- Then run `/release-notes:draft <version>`, from the ReleaseTools plugin. It
46
+ Then run `/release-notes:prepare <version>`, from the ReleaseTools plugin. It
47
47
  rules on every commit since the previous tag, writes the body to
48
48
  `.git/RELEASE_EDITMSG`, and puts the entry in `CHANGELOG.md` under
49
49
  `## <version> - <date>`. It shows the draft and waits before its last step,
@@ -86,15 +86,18 @@ tagged and nothing published.
86
86
  ## 4. Merge, then wait for main
87
87
 
88
88
  ```bash
89
- gh pr merge --rebase --delete-branch
89
+ gh pr merge --squash --delete-branch
90
90
  git switch main && git pull --ff-only
91
91
  rt github::await_workflow "$(git rev-parse HEAD)" tests.yml
92
92
  ```
93
93
 
94
- `--rebase` keeps the commit message rather than replacing it with the pull
95
- request title.
94
+ `--squash`, because the `main` ruleset requires signed commits and linear
95
+ history: GitHub cannot sign the commits a rebase merge writes and refuses it
96
+ with `Base branch requires signed commits`, and a merge commit is off. A
97
+ squash is signed by GitHub's web-flow key, which is expected here and not
98
+ something to route around. The subject becomes `Release <version> (#NN)`.
96
99
 
97
- The wait matters. A rebase onto a main that moved is a tree neither the
100
+ The wait matters. A squash onto a main that moved is a tree neither the
98
101
  branch nor main has tested, and the tag must only ever land on a commit
99
102
  already proved green. `github::await_workflow` blocks until `tests.yml` finishes
100
103
  on that commit and exits non-zero if it failed.
@@ -113,7 +116,14 @@ gh run watch "$(gh run list --workflow publish.yml --limit 1 --json databaseId -
113
116
 
114
117
  Pushing the tag is the release. `publish.yml` re-checks the tag against
115
118
  `pyproject.toml`, that the commit is on `main`, and the tests, then builds
116
- once and uploads to TestPyPI before PyPI.
119
+ once, installs the wheel it built and uploads it to PyPI. There is no
120
+ TestPyPI upload here; `testpypi.yml` published this commit when the merge put
121
+ it on `main`.
122
+
123
+ `release` needs that upload alone, so the GitHub release appears while
124
+ `verify` is still installing from PyPI. A red `verify` under a green
125
+ `publish` means the index has not caught up, not that the version is
126
+ missing.
117
127
 
118
128
  Report the PyPI URL and the GitHub release URL when it finishes.
119
129
 
@@ -4,16 +4,18 @@ name: Publish
4
4
  # request against the repository, this workflow's FILENAME and the environment,
5
5
  # so a rename means re-registering the publisher on PyPI first.
6
6
  #
7
- # build -> publish-test -> publish -> release
8
- # \-> verify
7
+ # build -> publish -> release
8
+ # \-> verify
9
9
  #
10
10
  # Nothing here creates a tag. A release is a version bump merged into main and
11
11
  # then tagged by hand, so the irreversible step is the last one and everything
12
12
  # before it is a normal reviewed change.
13
13
  #
14
- # TestPyPI gates the real upload deliberately: a PyPI upload cannot be undone
15
- # or replaced, so if the rehearsal fails there is nothing to pin against and
16
- # the run stops there.
14
+ # There is no TestPyPI rehearsal. testpypi.yml publishes every commit on main
15
+ # there as <version>.post<epoch> and installs it back, so the tree this tag
16
+ # names has already proved it uploads to an index and installs from one. What
17
+ # is left to prove before the upload PyPI cannot take back is that the wheel
18
+ # runs, and build installs it and does that.
17
19
  #
18
20
  # No API token is stored anywhere. See docs/DEVELOPMENT.md, "Publishing a
19
21
  # version".
@@ -31,7 +33,7 @@ permissions:
31
33
 
32
34
  jobs:
33
35
  # Built once and handed to every job below, so what reaches PyPI is
34
- # byte-for-byte what TestPyPI accepted.
36
+ # byte-for-byte what was tested and installed here.
35
37
  build:
36
38
  name: Build
37
39
  runs-on: ubuntu-latest
@@ -104,6 +106,17 @@ jobs:
104
106
  - name: Build
105
107
  run: uv build
106
108
 
109
+ # The wheel is not the tree: hatch_build.py writes _version.py into it,
110
+ # and one that lacks it falls back to the metadata lookup and says
111
+ # nothing. Installing it costs four seconds and happens before anything
112
+ # is uploaded anywhere.
113
+ - name: Install the wheel and run it
114
+ run: |
115
+ REPORTED="$(uv tool run --isolated --from dist/*.whl gw version)"
116
+ DECLARED="$(uv version --short)"
117
+ echo "the wheel reports $REPORTED, pyproject declares $DECLARED" >&2
118
+ [ "$REPORTED" = "$DECLARED" ]
119
+
107
120
  - name: Upload distributions
108
121
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
109
122
  with:
@@ -111,44 +124,10 @@ jobs:
111
124
  path: dist/
112
125
  if-no-files-found: error
113
126
 
114
- publish-test:
115
- name: Publish to TestPyPI
116
- runs-on: ubuntu-latest
117
- needs: build
118
- # Binds this job to the trusted publisher registered for `testpypi`.
119
- environment: testpypi
120
- permissions:
121
- # Lets the job mint the OIDC token PyPI exchanges for a short-lived,
122
- # scoped credential. Without it uv has nothing to present.
123
- id-token: write
124
- contents: read
125
- steps:
126
- # `--index testpypi` resolves that name from [[tool.uv.index]] in
127
- # pyproject.toml, which is not part of the built artifact.
128
- - name: Checkout code
129
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
130
-
131
- - name: Set up uv
132
- uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
133
- with:
134
- python-version: "3.14"
135
- enable-cache: true
136
-
137
- - name: Download distributions
138
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
139
- with:
140
- name: dist
141
- path: dist/
142
-
143
- - name: Publish
144
- # `always` rather than the default `automatic`: fail loudly if OIDC is
145
- # unavailable instead of quietly looking for a token.
146
- run: uv publish --index testpypi --trusted-publishing always
147
-
148
127
  publish:
149
128
  name: Publish to PyPI
150
129
  runs-on: ubuntu-latest
151
- needs: publish-test
130
+ needs: build
152
131
  # Binds this job to the trusted publisher registered for `pypi`. Add a
153
132
  # required reviewer on this environment for a manual gate before anything
154
133
  # reaches the real index.
@@ -0,0 +1,69 @@
1
+ name: TestPyPI
2
+
3
+ # Renaming this file breaks the upload. TestPyPI's Trusted Publishing matches a
4
+ # request against the repository, this workflow's FILENAME and the environment,
5
+ # so a rename means re-registering the publisher on TestPyPI first.
6
+ #
7
+ # Every commit on main is published here as <version>.post<epoch>. A build made
8
+ # after <version> shipped is a post release of it, which is what PEP 440's post
9
+ # segment says, and it sorts above <version> and below the next patch, so the
10
+ # newest snapshot is what TestPyPI serves.
11
+ #
12
+ # This is what proves the publishing path. The tree a release tag names has
13
+ # already been uploaded and installed back from an index by the time the tag
14
+ # exists, which is why publish.yml rehearses nothing of its own.
15
+ #
16
+ # The suite is not run again here. tests.yml runs it on this same commit, and a
17
+ # snapshot a test would have caught costs one number on a test index.
18
+
19
+ on:
20
+ push:
21
+ branches:
22
+ - main
23
+ workflow_dispatch:
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ jobs:
29
+ snapshot:
30
+ name: Publish a snapshot
31
+ runs-on: ubuntu-latest
32
+ # Binds this job to the trusted publisher registered for `testpypi`.
33
+ environment: testpypi
34
+ permissions:
35
+ # Lets the job mint the OIDC token TestPyPI exchanges for a short-lived,
36
+ # scoped credential. Without it uv has nothing to present.
37
+ id-token: write
38
+ contents: read
39
+ steps:
40
+ # `--index testpypi` resolves that name from [[tool.uv.index]] in
41
+ # pyproject.toml, and the script reads the version from the same file.
42
+ - name: Checkout code
43
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
44
+
45
+ - name: Set up uv
46
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
47
+ with:
48
+ python-version: "3.14"
49
+ enable-cache: true
50
+
51
+ # Epoch seconds rather than the run number: a re-run repeats the number
52
+ # and TestPyPI refuses the duplicate, while the clock has moved on.
53
+ # `--frozen` writes pyproject.toml and leaves uv.lock alone, which is
54
+ # correct here because nothing in this job resolves dependencies.
55
+ - name: Number the snapshot
56
+ run: uv version --frozen "$(uv version --short).post$(date +%s)"
57
+
58
+ - name: Build
59
+ run: uv build
60
+
61
+ - name: Publish
62
+ # `always` rather than the default `automatic`: fail loudly if OIDC is
63
+ # unavailable instead of quietly looking for a token.
64
+ run: uv publish --index testpypi --trusted-publishing always
65
+
66
+ # Uploaded is not installable, and this is the claim the release path
67
+ # inherits from these runs.
68
+ - name: Verify it installs from TestPyPI
69
+ run: scripts/verify-publish.bash
@@ -56,6 +56,11 @@ jobs:
56
56
  - name: Checkout code
57
57
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
58
58
 
59
+ # python-version makes setup-uv run `uv python install`, so each entry
60
+ # gets a uv-managed cpython-X.Y.Z rather than whatever the runner image
61
+ # ships. Packagers differ enough to matter: one that links its C
62
+ # extensions into the executable and one that dlopens 76 of them do not
63
+ # start in the same time. scripts/README.md has the measurement.
59
64
  - name: Set up uv and Python ${{ matrix.python-version }}
60
65
  uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
61
66
  with:
@@ -6,10 +6,20 @@ through `subprocess` so env, cwd, both streams and the exit code stay per-test.
6
6
 
7
7
  ```console
8
8
  uv sync --all-extras
9
+ uv run --no-sync pre-commit install
9
10
  uv run --no-sync pytest tests
10
11
  uv run --no-sync pre-commit run --all-files
11
12
  ```
12
13
 
14
+ `pre-commit run --all-files` reads `git ls-files`, so a file that is new and
15
+ unstaged is invisible to it. Stage before running the hooks, or install them
16
+ and let the commit do it.
17
+
18
+ Read [scripts/README.md](scripts/README.md) before measuring anything. It says
19
+ what is worth timing and what is not, and why every interpreter the benchmark
20
+ touches comes from uv: a run that compares packagers rather than versions
21
+ produces a number that looks like a CPython regression and is not one.
22
+
13
23
  ## Binaries and shell functions are not interchangeable
14
24
 
15
25
  A shell function of a given name beats a binary of that name in bash, zsh and
@@ -19,7 +29,12 @@ prompt and from a script unless the function is a pure adapter.
19
29
 
20
30
  The rule: **a function exists only for a command that must change the caller's
21
31
  directory, it carries the same name as the binary, and its body does nothing but
22
- call `command <name>` and `cd` to what that prints.**
32
+ call `command <name>` and `cd` to what that prints, when that is a directory.**
33
+
34
+ The last clause is not a hedge. `--json` and `--list` print what was asked for
35
+ on the same stream a destination arrives on, and a shim that cannot tell them
36
+ apart tries to `cd` into a JSON object. `fish -c 'gwl --json'` reaches the
37
+ function too, so this is the agent path as much as the human one.
23
38
 
24
39
  | | changes directory | ships as |
25
40
  | --- | --- | --- |
@@ -129,7 +144,9 @@ tightly they cluster. About twenty lines, `difflib` not required.
129
144
 
130
145
  Two rules, both the shape `gwp`'s prompt already has:
131
146
 
132
- - One match takes it outright, with no prompt at all.
147
+ - A query that narrows to one takes it outright, with no prompt at all. No
148
+ query asks however many there are, because being moved without being asked
149
+ is not what `gwl` with no argument means.
133
150
  - No terminal, no prompt. Refuse at exit 3 and name the flag that answers
134
151
  without one, rather than blocking on something nothing can drive.
135
152
 
@@ -1,9 +1,14 @@
1
1
  # Changelog
2
2
 
3
3
  Newest first. Each entry says what changed for somebody running these
4
- commands, and the choices behind it. Dates are ISO 8601, versions follow
5
- [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and the shape
6
- follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+ commands, and the choices behind it. Dates are ISO 8601, released versions
5
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and the
6
+ shape follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
+
8
+ Every version below was tagged and published. The snapshots `main` sends to
9
+ TestPyPI are neither, and they are numbered `<version>.post<epoch>`, which
10
+ PEP 440 orders after the release it follows and SemVer does not describe at
11
+ all. `docs/DEVELOPMENT.md` says how and why.
7
12
 
8
13
  An entry earns its place by being observable. If running a command produces
9
14
  no different result, no different output and no different exit code, it is
@@ -12,6 +17,47 @@ not in here, whatever it cost to build.
12
17
  A release closes with a `### Choices` section when a decision in it is worth
13
18
  the reader's time: what was chosen, and what the alternative failed to do.
14
19
 
20
+ ## 0.3.0 - 2026-09-20
21
+
22
+ `gwl` with no query asks which worktree rather than moving you to one.
23
+ Whenever exactly one was left it took it outright, so a repository holding
24
+ the main checkout and one worktree moved you without a word, and running it
25
+ twice walked you back and forth. A query that narrows to one has already said
26
+ which and still takes it outright. Without a terminal it now refuses at exit
27
+ 3 and names `--list` and `--json`, where it used to print a path, so
28
+ `cd $(gwl)` in a script stops working. `gwl <query>` is unchanged.
29
+
30
+ `gwl --list` no longer tries to `cd` into its own listing, and `--json` no
31
+ longer tries to `cd` into the JSON. The shell function reads what the binary
32
+ printed and moves you there, and those two modes print on the same stream a
33
+ destination arrives on, so every one of them ended in a failed `cd`. `gwa
34
+ --json` was the worst: the worktree was created and then the move failed, so
35
+ the run looked broken and was not. It reached `gwa`, `gwl`, `gwm` and `gwr`
36
+ in both fish and zsh, and `fish -c 'gwl --json'` too, because a fish function
37
+ wins there. **That fix lives in the shell functions, so `uv tool upgrade`
38
+ alone does not deliver it; update the fish or zsh plugin as well.**
39
+
40
+ An invocation costs about 18 ms less on Python 3.11 and about 12 ms less on
41
+ 3.14. `gw version` was 48.4 ms and is 30.5 ms on 3.11.13, and was 55.6 ms and
42
+ is 43.1 ms on 3.14.7, measured from installed wheels on one machine. Four
43
+ changes account for it: the command you typed imports only what it needs
44
+ rather than every command, records are plain classes rather than dataclasses,
45
+ the parser is built for the subcommand you asked for rather than all eleven,
46
+ and `json` is imported only where it is printed. `difflib`, which named the
47
+ closest command when you mistyped one, is no longer imported to start.
48
+
49
+ ### Choices
50
+
51
+ `gwl` asks whenever no query was given, even when the repository holds one
52
+ other worktree. Taking it outright reads as a toggle: the command moves you,
53
+ and moves you back, and never says where. Prompting for a single option looks
54
+ redundant until it is the difference between being shown your options and
55
+ being sent somewhere. A query is the way to skip it, and it always was.
56
+
57
+ `gwl` without a terminal exits 3, joining `gwp`, which 0.2.3 moved there for
58
+ the same reason. A script can then tell a refusal from a typo: 2 is
59
+ argparse's code for a usage error and 3 is nobody else's.
60
+
15
61
  ## 0.2.3 - 2026-09-11
16
62
 
17
63
  An unreachable remote is an answer rather than a failure. Every command that
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-worktrees
3
- Version: 0.2.3
3
+ Version: 0.3.0
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
@@ -148,8 +148,10 @@ autoload -Uz compinit && compinit
148
148
 
149
149
  ### What the shell code does
150
150
 
151
- Each function runs `command <name>`, reads the one path the binary printed and
152
- `cd`s there, and carries nothing else. `gws`, `gwp`, `gwnb`, `gwrot` and `gwh`
151
+ Each function runs `command <name>` and `cd`s to what it printed, provided
152
+ that is a directory. `--json` and `--list` print what was asked for on the
153
+ same stream a destination arrives on, so the shim asks whether what it has is
154
+ somewhere to go and stays put when it is not. `gws`, `gwp`, `gwnb`, `gwrot` and `gwh`
153
155
  change no directory, so they ship as console scripts alone and answer the same
154
156
  from a prompt and from a script.
155
157
 
@@ -283,9 +285,10 @@ $ gwm parser-v2 # renames the branch and moves the checkout
283
285
  $ gwr # removes the one you are standing in
284
286
  ```
285
287
 
286
- Each prints one destination on stdout and nothing else, which is what the
287
- shell function reads. `gwr` prints a path **only** when you were standing in
288
- what it removed; empty output means stay put.
288
+ Each prints one destination on stdout, which is what the shell function
289
+ reads, and prints data there instead under `--json` and `--list`. `gwr` prints
290
+ a path **only** when you were standing in what it removed; empty output, and
291
+ output that is not a directory, both mean stay put.
289
292
 
290
293
  `gwm` is the one that is not a convenience. Renaming the directory you are
291
294
  standing in leaves the shell with a stale `$PWD` and every later command
@@ -310,16 +313,17 @@ $ gwl --list
310
313
  ```
311
314
 
312
315
  It never offers that one. Picking it is the one answer that cannot take you
313
- anywhere, and a picker whose single candidate is where you already are prints
314
- nothing at all, which reads as a broken command. So two worktrees and no query
315
- is not a question, and asking for the one you are in says so and stays put:
316
+ anywhere, and asking for the one you are in says so and stays put:
316
317
 
317
318
  ```console
318
319
  $ gwl dirty # standing in dirty-work
319
320
  already in dirty-work
320
321
  ```
321
322
 
322
- With more than one worth offering it asks, numbered:
323
+ A query that narrows to one takes it outright. No query does not, even when
324
+ the repository holds exactly one other worktree: `gwl` with no argument means
325
+ show me the options, and being moved without being asked is not that. So it
326
+ asks, numbered, however many there are:
323
327
 
324
328
  ```console
325
329
  $ gwl # standing in the main checkout
@@ -122,8 +122,10 @@ autoload -Uz compinit && compinit
122
122
 
123
123
  ### What the shell code does
124
124
 
125
- Each function runs `command <name>`, reads the one path the binary printed and
126
- `cd`s there, and carries nothing else. `gws`, `gwp`, `gwnb`, `gwrot` and `gwh`
125
+ Each function runs `command <name>` and `cd`s to what it printed, provided
126
+ that is a directory. `--json` and `--list` print what was asked for on the
127
+ same stream a destination arrives on, so the shim asks whether what it has is
128
+ somewhere to go and stays put when it is not. `gws`, `gwp`, `gwnb`, `gwrot` and `gwh`
127
129
  change no directory, so they ship as console scripts alone and answer the same
128
130
  from a prompt and from a script.
129
131
 
@@ -257,9 +259,10 @@ $ gwm parser-v2 # renames the branch and moves the checkout
257
259
  $ gwr # removes the one you are standing in
258
260
  ```
259
261
 
260
- Each prints one destination on stdout and nothing else, which is what the
261
- shell function reads. `gwr` prints a path **only** when you were standing in
262
- what it removed; empty output means stay put.
262
+ Each prints one destination on stdout, which is what the shell function
263
+ reads, and prints data there instead under `--json` and `--list`. `gwr` prints
264
+ a path **only** when you were standing in what it removed; empty output, and
265
+ output that is not a directory, both mean stay put.
263
266
 
264
267
  `gwm` is the one that is not a convenience. Renaming the directory you are
265
268
  standing in leaves the shell with a stale `$PWD` and every later command
@@ -284,16 +287,17 @@ $ gwl --list
284
287
  ```
285
288
 
286
289
  It never offers that one. Picking it is the one answer that cannot take you
287
- anywhere, and a picker whose single candidate is where you already are prints
288
- nothing at all, which reads as a broken command. So two worktrees and no query
289
- is not a question, and asking for the one you are in says so and stays put:
290
+ anywhere, and asking for the one you are in says so and stays put:
290
291
 
291
292
  ```console
292
293
  $ gwl dirty # standing in dirty-work
293
294
  already in dirty-work
294
295
  ```
295
296
 
296
- With more than one worth offering it asks, numbered:
297
+ A query that narrows to one takes it outright. No query does not, even when
298
+ the repository holds exactly one other worktree: `gwl` with no argument means
299
+ show me the options, and being moved without being asked is not that. So it
300
+ asks, numbered, however many there are:
297
301
 
298
302
  ```console
299
303
  $ gwl # standing in the main checkout
@@ -0,0 +1,88 @@
1
+ # What an invocation costs
2
+
3
+ Generated. Everything else this program does is a `git` subprocess or
4
+ a forge round trip, and both are facts about a repository and a
5
+ network rather than about the code, so starting is the only thing
6
+ timed here.
7
+
8
+ ```console
9
+ $ uv run scripts/benchmark.py > b.json
10
+ $ uv run scripts/benchmark.py --markdown --from b.json > docs/BENCHMARK.md
11
+ ```
12
+
13
+ The JSON keeps every module `-X importtime` named. This is one view of
14
+ it, and a question about a module missing below is a filter rather
15
+ than another run.
16
+
17
+ Measured 2026-09-13T00:01:21Z against `7f9f332`, version 0.2.3, on macOS-26.6.2-arm64-arm-64bit-Mach-O. 50 runs each, wall clock divided, after 3 discarded.
18
+
19
+ | | 3.11.13 | 3.12.14 | 3.13.15 | 3.14.7 |
20
+ | --- | --- | --- | --- | --- |
21
+ | the interpreter alone | 16.9 ms | 18.8 ms | 19.4 ms | 19.7 ms |
22
+ | gw version | 27.4 ms | 30.1 ms | 29.7 ms | 40.7 ms |
23
+ | gwh | 27.6 ms | 29.8 ms | 29.6 ms | 40.3 ms |
24
+ | gws --help | 28.0 ms | 31.0 ms | 30.6 ms | 41.5 ms |
25
+ | /bin/echo, for scale | 2.5 ms | 2.5 ms | 2.6 ms | 2.6 ms |
26
+
27
+ Every column is a uv-managed interpreter. That is the point of `--managed-python`:
28
+ without it uv takes whatever it finds first, and a run compares packagers rather
29
+ than versions. Homebrew's 3.14 ships 76 extension modules as shared objects
30
+ where the build below ships 2, and on macOS every `dlopen` pays a code-signature
31
+ check, which was worth 8 ms of interpreter startup on its own.
32
+
33
+ | how each was built | 3.11.13 | 3.12.14 | 3.13.15 | 3.14.7 |
34
+ | --- | --- | --- | --- | --- |
35
+ | modules linked into the executable | 99 | 95 | 97 | 101 |
36
+ | modules `dlopen`ed from `lib-dynload` | 4 | 3 | 2 | 2 |
37
+
38
+ ### 3.11.13, the 8 costliest imports
39
+
40
+ | module | self | cumulative |
41
+ | --- | --- | --- |
42
+ | `typing` | 1.4 ms | 1.4 ms |
43
+ | `enum` | 1.0 ms | 1.0 ms |
44
+ | `time` | 0.6 ms | 0.6 ms |
45
+ | `ipaddress` | 0.6 ms | 0.6 ms |
46
+ | `urllib.parse` | 0.5 ms | 1.1 ms |
47
+ | `gettext` | 0.5 ms | 0.5 ms |
48
+ | `argparse` | 0.5 ms | 3.2 ms |
49
+ | `pathlib` | 0.4 ms | 1.9 ms |
50
+
51
+ ### 3.12.14, the 8 costliest imports
52
+
53
+ | module | self | cumulative |
54
+ | --- | --- | --- |
55
+ | `typing` | 1.5 ms | 1.6 ms |
56
+ | `urllib.parse` | 0.9 ms | 1.8 ms |
57
+ | `ipaddress` | 0.8 ms | 0.8 ms |
58
+ | `enum` | 0.8 ms | 0.8 ms |
59
+ | `time` | 0.6 ms | 0.6 ms |
60
+ | `argparse` | 0.5 ms | 3.0 ms |
61
+ | `site` | 0.5 ms | 3.5 ms |
62
+ | `nt` | 0.5 ms | 0.5 ms |
63
+
64
+ ### 3.13.15, the 8 costliest imports
65
+
66
+ | module | self | cumulative |
67
+ | --- | --- | --- |
68
+ | `typing` | 1.2 ms | 1.2 ms |
69
+ | `enum` | 0.7 ms | 0.7 ms |
70
+ | `time` | 0.6 ms | 0.6 ms |
71
+ | `argparse` | 0.5 ms | 3.0 ms |
72
+ | `gettext` | 0.5 ms | 0.5 ms |
73
+ | `pathlib._local` | 0.5 ms | 1.3 ms |
74
+ | `site` | 0.5 ms | 3.6 ms |
75
+ | `encodings` | 0.5 ms | 1.1 ms |
76
+
77
+ ### 3.14.7, the 8 costliest imports
78
+
79
+ | module | self | cumulative |
80
+ | --- | --- | --- |
81
+ | `typing` | 1.3 ms | 1.3 ms |
82
+ | `enum` | 0.7 ms | 0.7 ms |
83
+ | `time` | 0.7 ms | 0.7 ms |
84
+ | `contextlib` | 0.6 ms | 1.9 ms |
85
+ | `site` | 0.5 ms | 4.0 ms |
86
+ | `argparse` | 0.5 ms | 2.8 ms |
87
+ | `_collections_abc` | 0.5 ms | 0.5 ms |
88
+ | `encodings` | 0.5 ms | 1.2 ms |