git-worktrees 0.2.0__tar.gz → 0.2.3__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 (75) hide show
  1. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.github/workflows/publish.yml +39 -14
  2. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.github/workflows/tests.yml +7 -1
  3. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.gitignore +3 -0
  4. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/AGENTS.md +27 -2
  5. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/CHANGELOG.md +56 -0
  6. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/PKG-INFO +23 -7
  7. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/README.md +21 -6
  8. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gw.fish +0 -1
  9. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwa.fish +0 -1
  10. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwh.fish +0 -1
  11. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwl.fish +0 -1
  12. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwm.fish +0 -1
  13. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwnb.fish +0 -1
  14. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwp.fish +0 -1
  15. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwr.fish +1 -1
  16. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gwrot.fish +0 -1
  17. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/completions/gws.fish +0 -1
  18. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/docs/DEVELOPMENT.md +7 -0
  19. git_worktrees-0.2.3/hatch_build.py +49 -0
  20. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/pyproject.toml +16 -3
  21. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/scripts/verify-publish.bash +37 -8
  22. git_worktrees-0.2.3/src/worktrees/__init__.py +15 -0
  23. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/cli.py +111 -40
  24. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/forge.py +8 -2
  25. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/git.py +89 -46
  26. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/new_branch.py +10 -5
  27. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/prune.py +36 -0
  28. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/repo.py +37 -25
  29. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/rotate.py +6 -3
  30. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/verdicts.py +5 -2
  31. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/worktree.py +8 -5
  32. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_cli.py +152 -2
  33. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_completions.py +1 -1
  34. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_gates.py +53 -2
  35. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_shims.py +16 -3
  36. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_version.py +35 -5
  37. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/uv.lock +36 -1
  38. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gw +0 -1
  39. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwa +0 -1
  40. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwh +0 -1
  41. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwl +0 -1
  42. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwm +0 -1
  43. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwnb +0 -1
  44. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwp +0 -1
  45. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwr +1 -1
  46. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwrot +0 -1
  47. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gws +0 -1
  48. git_worktrees-0.2.0/src/worktrees/__init__.py +0 -8
  49. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.agents/skills/release/SKILL.md +0 -0
  50. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.claude/settings.json +0 -0
  51. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.pre-commit-config.yaml +0 -0
  52. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/.python-version +0 -0
  53. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/LICENSE +0 -0
  54. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/conf.d/worktrees.fish +0 -0
  55. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/functions/gwa.fish +0 -0
  56. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/functions/gwl.fish +0 -0
  57. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/functions/gwm.fish +0 -0
  58. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/functions/gwr.fish +0 -0
  59. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/renovate.json +0 -0
  60. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/layout.py +0 -0
  61. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/merged.py +0 -0
  62. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/pick.py +0 -0
  63. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/src/worktrees/render.py +0 -0
  64. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/conftest.py +0 -0
  65. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_forge.py +0 -0
  66. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_new_branch.py +0 -0
  67. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_render.py +0 -0
  68. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_rotate.py +0 -0
  69. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_spec.py +0 -0
  70. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/tests/test_worktree.py +0 -0
  71. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwa +0 -0
  72. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwl +0 -0
  73. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwm +0 -0
  74. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwr +0 -0
  75. {git_worktrees-0.2.0 → git_worktrees-0.2.3}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -5,6 +5,7 @@ name: Publish
5
5
  # so a rename means re-registering the publisher on PyPI first.
6
6
  #
7
7
  # build -> publish-test -> publish -> release
8
+ # \-> verify
8
9
  #
9
10
  # Nothing here creates a tag. A release is a version bump merged into main and
10
11
  # then tagged by hand, so the irreversible step is the last one and everything
@@ -74,19 +75,28 @@ jobs:
74
75
  # release that should go ahead. The API needs no local history.
75
76
  - name: Check the commit is on main
76
77
  env:
77
- GH_TOKEN: ${{ github.token }}
78
- GH_REPO: ${{ github.repository }}
78
+ # GH_REPO carries the value the runner publishes as GITHUB_REPOSITORY: gh looks for
79
+ # GH_REPO and never at GITHUB_REPOSITORY. GITHUB_TOKEN is not a default variable at
80
+ # all, so the token has to be handed over rather than renamed.
81
+ GH_TOKEN: ${{ github.token }} # no runner default
82
+ GH_REPO: ${{ github.repository }} # = $GITHUB_REPOSITORY; gh reads GH_REPO
79
83
  run: rt github::assert_on_branch main
80
84
 
81
85
  - name: Wait for the tests on this commit
82
86
  env:
83
- GH_TOKEN: ${{ github.token }}
84
- GH_REPO: ${{ github.repository }}
85
- run: rt github::await_workflow "$GITHUB_SHA" tests.yml
87
+ GH_TOKEN: ${{ github.token }} # no runner default
88
+ GH_REPO: ${{ github.repository }} # = $GITHUB_REPOSITORY; gh reads GH_REPO
89
+ SHA: ${{ github.sha }} # = $GITHUB_SHA
90
+ run: rt github::await_workflow "$SHA" tests.yml
86
91
 
87
92
  - name: Install dependencies
88
93
  run: uv sync --all-extras --locked
89
94
 
95
+ # Neither shell is on the image, and the shim tests fail rather than
96
+ # skip under CI, so this job needs them exactly as tests.yml does.
97
+ - name: Install fish and zsh
98
+ run: sudo apt-get update && sudo apt-get install -y fish zsh
99
+
90
100
  # The tag is the last chance to catch a regression, and it costs 20s.
91
101
  - name: Run tests
92
102
  run: uv run --no-sync pytest tests -q
@@ -162,18 +172,33 @@ jobs:
162
172
  name: dist
163
173
  path: dist/
164
174
 
165
- - name: Install the release tools
166
- uses: releasetools/cli@v0
167
-
168
175
  - name: Publish
169
176
  run: uv publish --trusted-publishing always
170
177
 
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"
178
+ # Uploaded and installable are two claims with two timings, so they are two
179
+ # jobs. An index serves what it has accepted only after a delay of its own
180
+ # choosing: 0.2.1 took longer than the script waited, and a release that had
181
+ # published correctly went red and had to be re-run by hand. This job holds
182
+ # the second claim, `release` needs only the first, and a red `verify` says
183
+ # PyPI is slow rather than that the version is not out.
184
+ verify:
185
+ name: Verify it installs from PyPI
186
+ runs-on: ubuntu-latest
187
+ needs: publish
188
+ permissions:
189
+ contents: read
190
+ steps:
191
+ # The script reads the name and version from pyproject.toml through
192
+ # `uv version`, and installs from the index rather than from the
193
+ # artifact, so there is nothing to download here.
194
+ - name: Checkout code
195
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
196
+
197
+ - name: Set up uv
198
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
199
+ with:
200
+ python-version: "3.14"
201
+ enable-cache: true
177
202
 
178
203
  - name: Verify it installs from PyPI
179
204
  run: scripts/verify-publish.bash --prod
@@ -67,11 +67,17 @@ jobs:
67
67
  # resolving something the lock file never described.
68
68
  run: uv sync --all-extras --locked
69
69
 
70
+ # Neither shell is on the image. Without them the nine assertions that
71
+ # drive a real fish or zsh call pytest.skip, which is invisible in
72
+ # `pytest -q`, so the run goes green without either shim ever running.
73
+ - name: Install fish and zsh
74
+ run: sudo apt-get update && sudo apt-get install -y fish zsh
75
+
70
76
  # The suite drives real git: it builds repositories, adds worktrees and
71
77
  # removes them. Recording the version makes a failure that is git's
72
78
  # rather than ours readable from the log alone.
73
79
  - name: Record the git version
74
- run: git --version
80
+ run: git --version && fish --version && zsh --version
75
81
 
76
82
  - name: Run tests
77
83
  run: uv run --no-sync pytest tests -q
@@ -5,3 +5,6 @@ __pycache__/
5
5
  .mypy_cache/
6
6
  .venv/
7
7
  dist/
8
+
9
+ # Written by hatch_build.py during a build.
10
+ src/worktrees/_version.py
@@ -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.
@@ -233,10 +238,30 @@ tested on what it returns rather than on its exit code. `upstream_of` takes
233
238
  `ok=(0, 1, 128)`, so a typo in one of its flags exits 128, reads as "no
234
239
  upstream", and turns every branch into `unknown` with nothing failing.
235
240
 
241
+ The two calls that reach the network, `fetch` and `remote set-head --auto`,
242
+ take `ok=(0, 128)` for the same reason in reverse: a remote that cannot be
243
+ reached is an answer rather than a failure. The caller reads the result, says
244
+ so on stderr, and carries on with the refs already here, which is where
245
+ `--no-fetch` sends it on request. It also stops asking: a failed fetch means
246
+ the head-branch ladder is told it is offline, rather than spending a second
247
+ round trip proving it.
248
+
249
+ One `git status --porcelain --ignored=traditional` answers both questions a
250
+ worktree is asked. It is a strict superset of plain `--porcelain`, so `dirty`
251
+ is "a line that does not start with `!! `" and `ignored` is the rest. Asking
252
+ both ways was 18 of an assessment's 35 calls over nine worktrees.
253
+
236
254
  `guard()` runs on the resolved argv inside the wrapper, not at declaration, so
237
255
  no call site can assemble its way past it. `reset --hard`, a forced `checkout`
238
- or `switch`, `clean -f`, a bare `push --force`, `worktree remove --force` and
239
- `branch -D` are refused absolutely. There is no flag, and `--yes` least of all.
256
+ or `switch`, `clean -f`, a bare `push --force`, `worktree remove --force`,
257
+ `branch -D` and an `update-ref` delete that names no full sha are refused
258
+ absolutely. There is no flag, and `--yes` least of all.
259
+
260
+ Those seven are `RULES`, a tuple, each row carrying the name `--explain`
261
+ prints, the clause the refusal ends with, and the test. The footer `--explain`
262
+ prints is built from it. A rule written in one place and described in another
263
+ is a rule that goes quiet: the hand-written footer fell a rule behind the
264
+ moment `update-ref` was added, and nothing failed.
240
265
 
241
266
  `verdicts.py` declares no mutating command. A read-only command may not call
242
267
  one: a test reads the verbose log and asserts it.
@@ -12,6 +12,62 @@ 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.3 - 2026-09-11
16
+
17
+ An unreachable remote is an answer rather than a failure. Every command that
18
+ touches the network used to exit 1 with git's own message, so the warnings
19
+ written for the offline case never reached anybody. A failed fetch now says
20
+ the refs are the ones already here, and the assessment carries on.
21
+
22
+ Refusing to prompt exits 3 everywhere. `gwp` used to exit 2 when stdin was
23
+ not a terminal, which is argparse's code for a usage error, so a script could
24
+ not tell a refusal from a typo.
25
+
26
+ Removing a worktree names the stash entries made on its branch. A stash
27
+ outlives the branch while its subject goes on naming it, which is how it
28
+ becomes unfindable.
29
+
30
+ `gwr` asks the forge once, about the branch it is removing, instead of about
31
+ every worktree: three round trips became one in a three-worktree repository.
32
+ `--no-forge` turns it off there, as it already did for `gws` and `gwp`.
33
+
34
+ `--explain` lists all seven refusals the guard enforces. The footer was
35
+ written by hand and had been a rule behind since `update-ref` was added.
36
+
37
+ An invocation costs about 15 ms less, and an assessment makes half as many
38
+ `git status` calls.
39
+
40
+ ### Choices
41
+
42
+ The version is written into the wheel at build time rather than read back
43
+ through `importlib.metadata`, which cost 10 ms of a 67 ms invocation for a
44
+ string two commands print. Deriving it from the tag with `hatch-vcs` was the
45
+ alternative, and it inverts which of the tag and `pyproject.toml` has to
46
+ agree with the other.
47
+
48
+ ## 0.2.1 - 2026-09-11
49
+
50
+ `--version` is gone from every command. `gw version`, or `gw v`, prints the
51
+ version this package installs; `gws --version` and the rest now fail as an
52
+ unrecognised argument, so a script reading the version back has one line to
53
+ change.
54
+
55
+ A name `gw` does not carry names the closest one that exists, instead of
56
+ printing a usage line and all twenty-two commands under an argparse error:
57
+
58
+ $ gw verison
59
+ gw: there is no command 'verison'
60
+ the closest is: gw version
61
+ gw help lists every command
62
+
63
+ ### Choices
64
+
65
+ `version` installs no binary of its own, and `gwh` prints a `—` in its
66
+ column to say so. A tenth console script, for a question asked once, would
67
+ be a name nobody types. Keeping `--version` beside it was the alternative:
68
+ two spellings of one answer, on every command, each to be completed,
69
+ documented and tested.
70
+
15
71
  ## 0.2.0 - 2026-09-11
16
72
 
17
73
  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.3
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
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.11
21
21
  Provides-Extra: dev
22
+ Requires-Dist: hatchling==1.28.0; extra == 'dev'
22
23
  Requires-Dist: pre-commit==4.6.1; extra == 'dev'
23
24
  Requires-Dist: pytest==9.1.1; extra == 'dev'
24
25
  Description-Content-Type: text/markdown
@@ -222,6 +223,12 @@ function returns that set for both. What goes and what puts it back is printed
222
223
  before anything does, and neither `--quiet` nor `--yes` silences it. The sha in
223
224
  the restore line is full length, because that line is meant to be pasted.
224
225
 
226
+ A stash made on a branch that is about to go is named too. `refs/stash` is
227
+ per-repository and pins its own commits, so the entry survives the branch and
228
+ `git stash branch <new> stash@{0}` still recovers it. What does not survive is
229
+ the branch name in its subject, so the line says which entry is about to start
230
+ pointing at nothing.
231
+
225
232
  The checkout goes through plain `git worktree remove`, so every refusal git
226
233
  makes still applies. The branch goes through `git update-ref -d`, naming the
227
234
  sha the verdict was formed against: a branch somebody committed to in between
@@ -240,7 +247,8 @@ squash-merged /home/you/git/.worktrees/squash-merged/repo
240
247
  not a terminal, so nothing can answer for the 1 above; pass --yes to remove them
241
248
  ```
242
249
 
243
- That exits 2 and touches nothing. `gwp --yes --json` is what an agent runs.
250
+ That exits 3 and touches nothing, the code every refusal uses. `gwp --yes --json`
251
+ is what an agent runs.
244
252
 
245
253
  With nothing to remove it prints the table `gws` prints, rather than the name
246
254
  of the command that would have printed it. The reason each worktree stayed is
@@ -404,6 +412,7 @@ gwr [QUERY] gw remove (rm) remove one whose branch is finished
404
412
  gwnb NAME gw new-branch (nb, new) branch off the head branch (alpha)
405
413
  gwrot gw rotate (rot) the next branch in a series (alpha)
406
414
  gwh gw help (h) every command and alias, this list
415
+ — gw version (v) the version this package installs
407
416
  ```
408
417
 
409
418
  `gw help`, `gw h` and `gw` with no subcommand print the same thing. One table
@@ -412,21 +421,26 @@ cannot disagree, and it raises at import if two commands ever claim one name.
412
421
 
413
422
  ## Flags
414
423
 
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:
424
+ `gw version`, or `gw v`, prints the version. No command takes a `--version`
425
+ flag. Every command but that one and `gwh`, which
426
+ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
427
+ `-v` and `--explain`. Beyond those:
418
428
 
419
429
  | | |
420
430
  | --- | --- |
421
431
  | `gws` | `--branch NAME`, `--no-fetch`, `--delete-ignored`, `--no-forge` |
422
432
  | `gwp` | those four, and `-y` |
423
- | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `-y` |
433
+ | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `--no-forge`, `-y` |
424
434
  | `gwa`, `gwnb`, `gwrot` | `--no-fetch` |
425
435
  | `gwl` | `-l` |
426
436
  | `gwm`, `gwh` | none |
427
437
 
428
438
  `gw <command> --help` prints one command's own list.
429
439
 
440
+ A remote that cannot be reached is not fatal anywhere. The fetch fails, the
441
+ command says so on stderr, and it answers from the refs already here, which is
442
+ where `--no-fetch` sends it on request.
443
+
430
444
  Data goes to stdout and diagnostics to stderr, the prompt included, so `--json`
431
445
  is parseable in every mode. Colour is decided per stream and only for a
432
446
  terminal, so a redirect, a pipe, a non-empty `NO_COLOR` or `TERM=dumb` give the
@@ -458,12 +472,14 @@ and whatever a caller asks for:
458
472
  ```
459
473
  reset --hard a forced checkout or switch clean -f
460
474
  push --force worktree remove --force branch -D
475
+ an update-ref delete that names no full sha
461
476
  ```
462
477
 
463
478
  The check runs on the argument list as it is about to be handed to git, so no
464
479
  code path can assemble its way past one. `gws --explain` prints every git
465
480
  command the program can issue, marking the ones that take the repository's
466
- shared refs and therefore run serially.
481
+ shared refs and therefore run serially, and ends with that list of seven,
482
+ generated from the rules rather than written out beside them.
467
483
 
468
484
  `--force-with-lease` is not `--force` and is allowed, and so is
469
485
  `update-ref -d <ref> <sha>`, which is how a branch is deleted here: against the
@@ -197,6 +197,12 @@ function returns that set for both. What goes and what puts it back is printed
197
197
  before anything does, and neither `--quiet` nor `--yes` silences it. The sha in
198
198
  the restore line is full length, because that line is meant to be pasted.
199
199
 
200
+ A stash made on a branch that is about to go is named too. `refs/stash` is
201
+ per-repository and pins its own commits, so the entry survives the branch and
202
+ `git stash branch <new> stash@{0}` still recovers it. What does not survive is
203
+ the branch name in its subject, so the line says which entry is about to start
204
+ pointing at nothing.
205
+
200
206
  The checkout goes through plain `git worktree remove`, so every refusal git
201
207
  makes still applies. The branch goes through `git update-ref -d`, naming the
202
208
  sha the verdict was formed against: a branch somebody committed to in between
@@ -215,7 +221,8 @@ squash-merged /home/you/git/.worktrees/squash-merged/repo
215
221
  not a terminal, so nothing can answer for the 1 above; pass --yes to remove them
216
222
  ```
217
223
 
218
- That exits 2 and touches nothing. `gwp --yes --json` is what an agent runs.
224
+ That exits 3 and touches nothing, the code every refusal uses. `gwp --yes --json`
225
+ is what an agent runs.
219
226
 
220
227
  With nothing to remove it prints the table `gws` prints, rather than the name
221
228
  of the command that would have printed it. The reason each worktree stayed is
@@ -379,6 +386,7 @@ gwr [QUERY] gw remove (rm) remove one whose branch is finished
379
386
  gwnb NAME gw new-branch (nb, new) branch off the head branch (alpha)
380
387
  gwrot gw rotate (rot) the next branch in a series (alpha)
381
388
  gwh gw help (h) every command and alias, this list
389
+ — gw version (v) the version this package installs
382
390
  ```
383
391
 
384
392
  `gw help`, `gw h` and `gw` with no subcommand print the same thing. One table
@@ -387,21 +395,26 @@ cannot disagree, and it raises at import if two commands ever claim one name.
387
395
 
388
396
  ## Flags
389
397
 
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:
398
+ `gw version`, or `gw v`, prints the version. No command takes a `--version`
399
+ flag. Every command but that one and `gwh`, which
400
+ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
401
+ `-v` and `--explain`. Beyond those:
393
402
 
394
403
  | | |
395
404
  | --- | --- |
396
405
  | `gws` | `--branch NAME`, `--no-fetch`, `--delete-ignored`, `--no-forge` |
397
406
  | `gwp` | those four, and `-y` |
398
- | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `-y` |
407
+ | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `--no-forge`, `-y` |
399
408
  | `gwa`, `gwnb`, `gwrot` | `--no-fetch` |
400
409
  | `gwl` | `-l` |
401
410
  | `gwm`, `gwh` | none |
402
411
 
403
412
  `gw <command> --help` prints one command's own list.
404
413
 
414
+ A remote that cannot be reached is not fatal anywhere. The fetch fails, the
415
+ command says so on stderr, and it answers from the refs already here, which is
416
+ where `--no-fetch` sends it on request.
417
+
405
418
  Data goes to stdout and diagnostics to stderr, the prompt included, so `--json`
406
419
  is parseable in every mode. Colour is decided per stream and only for a
407
420
  terminal, so a redirect, a pipe, a non-empty `NO_COLOR` or `TERM=dumb` give the
@@ -433,12 +446,14 @@ and whatever a caller asks for:
433
446
  ```
434
447
  reset --hard a forced checkout or switch clean -f
435
448
  push --force worktree remove --force branch -D
449
+ an update-ref delete that names no full sha
436
450
  ```
437
451
 
438
452
  The check runs on the argument list as it is about to be handed to git, so no
439
453
  code path can assemble its way past one. `gws --explain` prints every git
440
454
  command the program can issue, marking the ones that take the repository's
441
- shared refs and therefore run serially.
455
+ shared refs and therefore run serially, and ends with that list of seven,
456
+ generated from the rules rather than written out beside them.
442
457
 
443
458
  `--force-with-lease` is not `--force` and is allowed, and so is
444
459
  `update-ref -d <ref> <sha>`, which is how a branch is deleted here: against the
@@ -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'
@@ -7,6 +7,7 @@ complete -c gwr -a '(command gwr --complete)'
7
7
  complete -c gwr -s f -l force -d 'remove it even when unfinished'
8
8
  complete -c gwr -l delete-ignored -d 'also delete its gitignored files'
9
9
  complete -c gwr -l no-fetch -d 'use the refs already here'
10
+ complete -c gwr -l no-forge -d 'decide from git alone; never ask the forge'
10
11
  complete -c gwr -s y -l yes -d 'do not ask'
11
12
 
12
13
  complete -c gwr -l json -d 'the result as data'
@@ -14,5 +15,4 @@ complete -c gwr -s q -l quiet -d 'the path alone'
14
15
  complete -c gwr -s v -l verbose -d 'print every git command'
15
16
  complete -c gwr -l explain -d 'print every git command and exit'
16
17
 
17
- complete -c gwr -l version -d 'show the version and exit'
18
18
  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'
@@ -182,6 +182,7 @@ Pushing the tag starts `publish.yml`:
182
182
 
183
183
  ```
184
184
  build ──> publish-test ──> publish ──> release
185
+ └─> verify
185
186
  ```
186
187
 
187
188
  `build` runs three guards before it does anything, cheapest first. The tag has
@@ -196,6 +197,12 @@ replaced, so a failed rehearsal stops the run while there is still nothing to
196
197
  pin against. The merge happens first and the irreversible step is last, so
197
198
  everything recoverable is already done by the time anything is published.
198
199
 
200
+ `release` needs the upload and nothing else. `verify` installs the version
201
+ from PyPI and runs what it installed, on its own, because an index serves what
202
+ it has accepted after a delay it does not bound: 0.2.1 took longer than the
203
+ script waited and a correct release went red. A red `verify` says PyPI is
204
+ slow, not that the version is missing. It gives up after about 32 minutes.
205
+
199
206
  `workflow_dispatch` against a tag ref re-runs a release whose publish failed.
200
207
 
201
208
  No API token is stored anywhere. Both uploads use PyPI Trusted Publishing over
@@ -0,0 +1,49 @@
1
+ """Write the version `pyproject.toml` declares into the package it builds.
2
+
3
+ Reading it back through `importlib.metadata` at import instead costs 10 ms of
4
+ a 67 ms invocation, on every command, for a string two of them print. A
5
+ constant in the wheel costs nothing, and `pyproject.toml` stays the one place
6
+ the version is written.
7
+
8
+ Not `hatch-vcs`: deriving the version from a tag inverts which of the two has
9
+ to agree with the other, and `publish.yml` refuses a tag whose commit carries
10
+ a different version.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
19
+
20
+ GENERATED = Path("src") / "worktrees" / "_version.py"
21
+
22
+
23
+ def rendered(version: str) -> str:
24
+ """The file's whole contents."""
25
+ return (
26
+ '"""Written at build time. See hatch_build.py."""\n'
27
+ f'\n__version__ = "{version}"\n'
28
+ )
29
+
30
+
31
+ def generate(root: Path, version: str, build_data: dict[str, Any]) -> Path:
32
+ """Write the file, and tell the builder to ship it.
33
+
34
+ Both halves, because either alone is silent. `.gitignore` keeps the
35
+ generated file out of the repository and hatchling reads `.gitignore`, so
36
+ without the artifacts entry the wheel ships without it and every install
37
+ falls back to the metadata lookup with nothing to say so.
38
+ """
39
+ target = root / GENERATED
40
+ target.write_text(rendered(version))
41
+ build_data.setdefault("artifacts", []).append(f"/{GENERATED.as_posix()}")
42
+ return target
43
+
44
+
45
+ class VersionHook(BuildHookInterface):
46
+ PLUGIN_NAME = "custom"
47
+
48
+ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
49
+ generate(Path(self.root), self.metadata.version, build_data)
@@ -9,10 +9,10 @@ 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.3"
13
13
  # The standard library answers every question this asks. A dependency here
14
- # would be paid on every invocation, and the whole reason this is Python is
15
- # that an invocation costs ~25 ms.
14
+ # would be paid on every invocation, and an invocation is 67 ms of which 25 ms
15
+ # is the interpreter starting.
16
16
  dependencies = []
17
17
  requires-python = ">=3.11"
18
18
  classifiers = [
@@ -30,6 +30,9 @@ classifiers = [
30
30
  [project.optional-dependencies]
31
31
  dev = [
32
32
  # keep-sorted start
33
+ # The build backend, because tests/test_version.py drives the build hook
34
+ # that writes the version into the wheel.
35
+ "hatchling==1.28.0",
33
36
  "pre-commit==4.6.1",
34
37
  "pytest==9.1.1",
35
38
  # keep-sorted end
@@ -70,6 +73,11 @@ build-backend = "hatchling.build"
70
73
  [tool.hatch.build.targets.wheel]
71
74
  packages = ["src/worktrees"]
72
75
 
76
+ # Writes src/worktrees/_version.py from the version above, so an invocation
77
+ # reads a constant rather than asking importlib.metadata. See hatch_build.py.
78
+ [tool.hatch.build.targets.wheel.hooks.custom]
79
+ path = "hatch_build.py"
80
+
73
81
  [tool.ruff]
74
82
  line-length = 88
75
83
  target-version = "py311"
@@ -102,5 +110,10 @@ warn_unused_ignores = true
102
110
  # keep-sorted end
103
111
  files = ["src", "tests"]
104
112
 
113
+ [[tool.mypy.overrides]]
114
+ # Generated at build time and absent from a checkout.
115
+ module = "worktrees._version"
116
+ ignore_missing_imports = true
117
+
105
118
  [tool.pytest.ini_options]
106
119
  testpaths = ["tests"]
@@ -34,21 +34,50 @@ 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
+ # Eight attempts backing off from 15s is about 32 minutes, and it stops the moment the
43
+ # version resolves. Nothing waits on this job, so the budget is how long PyPI is given
44
+ # before somebody is told it is slow.
45
+ readonly ATTEMPTS=8
46
+ readonly BASE=15
47
+
48
+ # The version the installed command reports, compared against the one asked
38
49
  # of the index. Running it and discarding the output would pass while every
39
50
  # 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)"
51
+ attempt=0
52
+ delay="$BASE"
53
+ while :; do
54
+ attempt=$((attempt + 1))
55
+
56
+ if REPORTED="$(UV_CACHE_DIR="$TMP/cache" uv tool run \
57
+ --isolated \
58
+ --index "$INDEX" \
59
+ --index-strategy unsafe-best-match \
60
+ --from "$NAME==$VERSION" \
61
+ gw version 2>"$TMP/resolve.err")"; then
62
+ break
63
+ fi
64
+
65
+ if [ "$attempt" -ge "$ATTEMPTS" ]; then
66
+ cat "$TMP/resolve.err" >&2
67
+ echo "$WHICH did not serve $NAME==$VERSION after $ATTEMPTS attempts." >&2
68
+ exit 1
69
+ fi
70
+
71
+ echo "waiting ${delay}s for $WHICH to serve $NAME==$VERSION (attempt $attempt/$ATTEMPTS)..." >&2
72
+ sleep "$delay"
73
+ delay=$((delay * 2))
74
+ done
46
75
  readonly REPORTED
47
76
 
48
77
  [ "$REPORTED" = "$VERSION" ] || {
49
78
  echo "Version mismatch after publishing to $WHICH." >&2
50
79
  echo " asked the index for: $VERSION" >&2
51
- echo " gws --version says: $REPORTED" >&2
80
+ echo " gw version says: $REPORTED" >&2
52
81
  exit 1
53
82
  }
54
83