git-worktrees 0.2.1__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.1 → git_worktrees-0.2.3}/.github/workflows/publish.yml +31 -7
  2. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.github/workflows/tests.yml +7 -1
  3. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.gitignore +3 -0
  4. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/AGENTS.md +22 -2
  5. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/CHANGELOG.md +33 -0
  6. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/PKG-INFO +18 -4
  7. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/README.md +16 -3
  8. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwr.fish +1 -0
  9. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/docs/DEVELOPMENT.md +7 -0
  10. git_worktrees-0.2.3/hatch_build.py +49 -0
  11. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/pyproject.toml +16 -3
  12. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/scripts/verify-publish.bash +4 -3
  13. git_worktrees-0.2.3/src/worktrees/__init__.py +15 -0
  14. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/cli.py +59 -31
  15. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/forge.py +8 -2
  16. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/git.py +89 -46
  17. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/new_branch.py +10 -5
  18. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/prune.py +36 -0
  19. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/repo.py +37 -25
  20. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/rotate.py +6 -3
  21. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/verdicts.py +5 -2
  22. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/worktree.py +8 -5
  23. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_cli.py +121 -1
  24. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_gates.py +53 -2
  25. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_shims.py +16 -3
  26. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_version.py +30 -0
  27. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/uv.lock +36 -1
  28. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwr +1 -0
  29. git_worktrees-0.2.1/src/worktrees/__init__.py +0 -8
  30. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.agents/skills/release/SKILL.md +0 -0
  31. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.claude/settings.json +0 -0
  32. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.pre-commit-config.yaml +0 -0
  33. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.python-version +0 -0
  34. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/LICENSE +0 -0
  35. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gw.fish +0 -0
  36. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwa.fish +0 -0
  37. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwh.fish +0 -0
  38. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwl.fish +0 -0
  39. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwm.fish +0 -0
  40. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwnb.fish +0 -0
  41. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwp.fish +0 -0
  42. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwrot.fish +0 -0
  43. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gws.fish +0 -0
  44. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/conf.d/worktrees.fish +0 -0
  45. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwa.fish +0 -0
  46. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwl.fish +0 -0
  47. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwm.fish +0 -0
  48. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwr.fish +0 -0
  49. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/renovate.json +0 -0
  50. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/layout.py +0 -0
  51. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/merged.py +0 -0
  52. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/pick.py +0 -0
  53. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/render.py +0 -0
  54. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/conftest.py +0 -0
  55. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_completions.py +0 -0
  56. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_forge.py +0 -0
  57. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_new_branch.py +0 -0
  58. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_render.py +0 -0
  59. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_rotate.py +0 -0
  60. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_spec.py +0 -0
  61. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_worktree.py +0 -0
  62. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gw +0 -0
  63. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwa +0 -0
  64. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwh +0 -0
  65. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwl +0 -0
  66. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwm +0 -0
  67. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwnb +0 -0
  68. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwp +0 -0
  69. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwrot +0 -0
  70. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gws +0 -0
  71. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwa +0 -0
  72. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwl +0 -0
  73. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwm +0 -0
  74. {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwr +0 -0
  75. {git_worktrees-0.2.1 → 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
@@ -91,6 +92,11 @@ jobs:
91
92
  - name: Install dependencies
92
93
  run: uv sync --all-extras --locked
93
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
+
94
100
  # The tag is the last chance to catch a regression, and it costs 20s.
95
101
  - name: Run tests
96
102
  run: uv run --no-sync pytest tests -q
@@ -166,16 +172,34 @@ jobs:
166
172
  name: dist
167
173
  path: dist/
168
174
 
169
- - name: Install the release tools
170
- uses: releasetools/cli@v0
171
-
172
175
  - name: Publish
173
176
  run: uv publish --trusted-publishing always
174
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
+ # 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
202
+
179
203
  - name: Verify it installs from PyPI
180
204
  run: scripts/verify-publish.bash --prod
181
205
 
@@ -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
@@ -238,10 +238,30 @@ tested on what it returns rather than on its exit code. `upstream_of` takes
238
238
  `ok=(0, 1, 128)`, so a typo in one of its flags exits 128, reads as "no
239
239
  upstream", and turns every branch into `unknown` with nothing failing.
240
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
+
241
254
  `guard()` runs on the resolved argv inside the wrapper, not at declaration, so
242
255
  no call site can assemble its way past it. `reset --hard`, a forced `checkout`
243
- or `switch`, `clean -f`, a bare `push --force`, `worktree remove --force` and
244
- `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.
245
265
 
246
266
  `verdicts.py` declares no mutating command. A read-only command may not call
247
267
  one: a test reads the verbose log and asserts it.
@@ -12,6 +12,39 @@ 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
+
15
48
  ## 0.2.1 - 2026-09-11
16
49
 
17
50
  `--version` is gone from every command. `gw version`, or `gw v`, prints the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-worktrees
3
- Version: 0.2.1
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
@@ -422,13 +430,17 @@ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
422
430
  | --- | --- |
423
431
  | `gws` | `--branch NAME`, `--no-fetch`, `--delete-ignored`, `--no-forge` |
424
432
  | `gwp` | those four, and `-y` |
425
- | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `-y` |
433
+ | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `--no-forge`, `-y` |
426
434
  | `gwa`, `gwnb`, `gwrot` | `--no-fetch` |
427
435
  | `gwl` | `-l` |
428
436
  | `gwm`, `gwh` | none |
429
437
 
430
438
  `gw <command> --help` prints one command's own list.
431
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
+
432
444
  Data goes to stdout and diagnostics to stderr, the prompt included, so `--json`
433
445
  is parseable in every mode. Colour is decided per stream and only for a
434
446
  terminal, so a redirect, a pipe, a non-empty `NO_COLOR` or `TERM=dumb` give the
@@ -460,12 +472,14 @@ and whatever a caller asks for:
460
472
  ```
461
473
  reset --hard a forced checkout or switch clean -f
462
474
  push --force worktree remove --force branch -D
475
+ an update-ref delete that names no full sha
463
476
  ```
464
477
 
465
478
  The check runs on the argument list as it is about to be handed to git, so no
466
479
  code path can assemble its way past one. `gws --explain` prints every git
467
480
  command the program can issue, marking the ones that take the repository's
468
- 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.
469
483
 
470
484
  `--force-with-lease` is not `--force` and is allowed, and so is
471
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
@@ -397,13 +404,17 @@ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
397
404
  | --- | --- |
398
405
  | `gws` | `--branch NAME`, `--no-fetch`, `--delete-ignored`, `--no-forge` |
399
406
  | `gwp` | those four, and `-y` |
400
- | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `-y` |
407
+ | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `--no-forge`, `-y` |
401
408
  | `gwa`, `gwnb`, `gwrot` | `--no-fetch` |
402
409
  | `gwl` | `-l` |
403
410
  | `gwm`, `gwh` | none |
404
411
 
405
412
  `gw <command> --help` prints one command's own list.
406
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
+
407
418
  Data goes to stdout and diagnostics to stderr, the prompt included, so `--json`
408
419
  is parseable in every mode. Colour is decided per stream and only for a
409
420
  terminal, so a redirect, a pipe, a non-empty `NO_COLOR` or `TERM=dumb` give the
@@ -435,12 +446,14 @@ and whatever a caller asks for:
435
446
  ```
436
447
  reset --hard a forced checkout or switch clean -f
437
448
  push --force worktree remove --force branch -D
449
+ an update-ref delete that names no full sha
438
450
  ```
439
451
 
440
452
  The check runs on the argument list as it is about to be handed to git, so no
441
453
  code path can assemble its way past one. `gws --explain` prints every git
442
454
  command the program can issue, marking the ones that take the repository's
443
- 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.
444
457
 
445
458
  `--force-with-lease` is not `--force` and is allowed, and so is
446
459
  `update-ref -d <ref> <sha>`, which is how a branch is deleted here: against the
@@ -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'
@@ -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.1"
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"]
@@ -39,9 +39,10 @@ trap 'rm -rf "$TMP"' EXIT
39
39
  # still could not find it. So the wait is this resolution retried rather than a different
40
40
  # endpoint polled, because this is the condition a green publish is claiming.
41
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
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
45
46
  readonly BASE=15
46
47
 
47
48
  # The version the installed command reports, compared against the one asked
@@ -0,0 +1,15 @@
1
+ """Git worktree commands that refuse to lose work."""
2
+
3
+ try:
4
+ # Written into the wheel by hatch_build.py, from the version
5
+ # pyproject.toml declares. Asking importlib.metadata for it instead costs
6
+ # 10 ms of a 67 ms invocation, on every command, for a string two of them
7
+ # print.
8
+ from ._version import __version__
9
+ except ImportError: # a checkout, or an editable install of one
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("git-worktrees")
14
+ except PackageNotFoundError: # a source tree nothing has installed
15
+ __version__ = "0+unknown"
@@ -14,7 +14,7 @@ from . import __version__, new_branch, pick, prune, render, verdicts
14
14
  from . import repo as R
15
15
  from . import rotate as rotate_mod
16
16
  from . import worktree as wt_mod
17
- from .git import GitError, Refused, commands, options
17
+ from .git import RULES, GitError, Refused, commands, options
18
18
  from .render import BLUE, BOLD, DIM, GREEN, RED, YELLOW, Cell, Row, table
19
19
 
20
20
 
@@ -79,11 +79,20 @@ def _explain() -> int:
79
79
  )
80
80
  print(table(rows))
81
81
  print()
82
+ # Generated from the guard itself. The footer used to be a sentence
83
+ # written by hand in this file, and it fell a rule behind the moment one
84
+ # was added.
85
+ import textwrap
86
+
87
+ named = [rule.named for rule in RULES]
88
+ listed = ", ".join(named[:-1]) + " and " + named[-1]
82
89
  print(
83
- "! takes the repository's shared refs and runs serially. The guard "
84
- "refuses\n reset --hard, a forced checkout or switch, clean -f, push "
85
- "--force,\n worktree remove --force and branch -D outright, whatever "
86
- "flags are passed."
90
+ textwrap.fill(
91
+ "! takes the repository's shared refs and runs serially. The guard "
92
+ f"refuses these outright, whatever flags are passed: {listed}.",
93
+ width=76,
94
+ subsequent_indent=" ",
95
+ )
87
96
  )
88
97
  return 0
89
98
 
@@ -130,7 +139,8 @@ class _Stop(Exception):
130
139
  def _assess(
131
140
  args: argparse.Namespace,
132
141
  judge: Callable[..., list[verdicts.Verdict]] | None = None,
133
- ) -> tuple[list[verdicts.Verdict], list[R.Worktree], str]:
142
+ ask_forge: bool | None = None,
143
+ ) -> tuple[list[verdicts.Verdict], list[R.Worktree], str, str]:
134
144
  """Fetch, resolve the head branch, and judge every worktree.
135
145
 
136
146
  The one path every command takes, so `prune` can only ever act on what
@@ -138,13 +148,21 @@ def _assess(
138
148
  to be judged like any other, having stepped out of it first.
139
149
  """
140
150
  remote = R.remote()
141
- if not args.no_fetch:
142
- if remote:
143
- R.fetch(remote)
151
+ online = not args.no_fetch
152
+ if online:
153
+ # An unreachable remote is not a reason to refuse to answer. It lands
154
+ # where --no-fetch already goes, and says so, and the head-branch
155
+ # ladder is told not to spend a second round trip on the same remote.
156
+ if remote and not R.fetch(remote):
157
+ online = False
158
+ if not args.quiet:
159
+ _err(
160
+ f"{remote} could not be fetched; judging from the refs already here"
161
+ )
144
162
  elif not args.quiet:
145
163
  _err("using the refs already here; they may be stale (--no-fetch)")
146
164
 
147
- head, warning = R.head_ref(remote, online=not args.no_fetch)
165
+ head, warning = R.head_ref(remote, online=online)
148
166
  if warning and not args.quiet:
149
167
  _err(warning)
150
168
  if not head:
@@ -160,7 +178,8 @@ def _assess(
160
178
  # round trip, and a branch merged as part of a stack is the one case
161
179
  # content cannot answer: its changes reach the head branch across several
162
180
  # squashes, so a stale intermediate and real work look alike to a diff.
163
- ask_forge = not getattr(args, "no_forge", False)
181
+ if ask_forge is None:
182
+ ask_forge = not getattr(args, "no_forge", False)
164
183
  rows = (
165
184
  judge(only, head, head_branch, ignored, ask_forge=ask_forge)
166
185
  if judge
@@ -168,7 +187,7 @@ def _assess(
168
187
  records, only, head, head_branch, ignored, ask_forge=ask_forge
169
188
  )
170
189
  )
171
- return rows, verdicts.stale(records), head
190
+ return rows, verdicts.stale(records), head, head_branch
172
191
 
173
192
 
174
193
  # --------------------------------------------------------------------------
@@ -182,7 +201,7 @@ def run_status(args: argparse.Namespace) -> int:
182
201
  if args.explain:
183
202
  return _explain()
184
203
 
185
- rows, stale, head = _assess(args)
204
+ rows, stale, head, _ = _assess(args)
186
205
  go = prune.removable(rows)
187
206
  unknown = [v for v in rows if v.verdict == verdicts.UNKNOWN]
188
207
 
@@ -253,7 +272,7 @@ def run_prune(args: argparse.Namespace) -> int:
253
272
  # neither command may propose removing one.
254
273
  prune.worktree_prune()
255
274
 
256
- rows, _, _ = _assess(args)
275
+ rows, _, _, _ = _assess(args)
257
276
  go = prune.removable(rows)
258
277
  unknown = [v for v in rows if v.verdict == verdicts.UNKNOWN]
259
278
 
@@ -280,14 +299,11 @@ def run_prune(args: argparse.Namespace) -> int:
280
299
  # --quiet and --yes do not silence it.
281
300
  prune.plan(go, _err)
282
301
 
283
- if not args.yes:
284
- try:
285
- if not prune.confirm(len(go)):
286
- _err("nothing removed")
287
- return 0
288
- except Refused as exc:
289
- _err(render.err(str(exc), RED))
290
- return 2
302
+ # A Refused from here goes to _dispatch, which exits 3 for every refusal.
303
+ # No terminal is one condition, so it is one code wherever it is met.
304
+ if not args.yes and not prune.confirm(len(go)):
305
+ _err("nothing removed")
306
+ return 0
291
307
 
292
308
  failed = prune.sweep(go, _err)
293
309
  removed = [v.label for v in go]
@@ -485,6 +501,11 @@ def _add_remove_flags(p: argparse.ArgumentParser) -> None:
485
501
  help="also delete its gitignored files; nothing restores them",
486
502
  )
487
503
  p.add_argument("--no-fetch", action="store_true", help="use the refs already here")
504
+ p.add_argument(
505
+ "--no-forge",
506
+ action="store_true",
507
+ help="decide from git alone; never ask the forge",
508
+ )
488
509
  p.add_argument("-y", "--yes", action="store_true", help="do not ask")
489
510
  # Its own, not gwl's: assess skips the main checkout, so gwr can never act
490
511
  # on it and offering it would complete to "no worktree matches".
@@ -637,7 +658,10 @@ def run_remove(args: argparse.Namespace) -> int:
637
658
  print(f"{w.label}\t{w.path}")
638
659
  return 0
639
660
 
640
- rows, _, _ = _assess(args, judge=wt_mod.removable)
661
+ # The picker shows a path and a branch and no verdict, so nothing before
662
+ # the answer needs the forge. Scanning without it turns one round trip per
663
+ # worktree into at most one for the whole command.
664
+ rows, _, head, head_branch = _assess(args, judge=wt_mod.removable, ask_forge=False)
641
665
  found = pick.matches(
642
666
  args.query, [R.Worktree(v.path, "", v.branch, frozenset()) for v in rows]
643
667
  )
@@ -660,6 +684,15 @@ def run_remove(args: argparse.Namespace) -> int:
660
684
  return 0
661
685
  chosen = by_path[picked.path]
662
686
 
687
+ # Now that there is one branch, the forge is worth a question: it is the
688
+ # only thing that settles a branch merged as part of a stack.
689
+ if chosen.verdict != prune.REMOVE and not args.no_forge:
690
+ judged = wt_mod.removable(
691
+ chosen.branch, head, head_branch, args.delete_ignored, ask_forge=True
692
+ )
693
+ if judged:
694
+ chosen = judged[0]
695
+
663
696
  if chosen.verdict != prune.REMOVE and not args.force:
664
697
  # A finished branch held back by ignored files is not an unfinished
665
698
  # branch. Saying so would contradict the reason printed beside it,
@@ -675,14 +708,9 @@ def run_remove(args: argparse.Namespace) -> int:
675
708
  return 1
676
709
 
677
710
  prune.plan([chosen], _err)
678
- if not args.yes:
679
- try:
680
- if not prune.confirm(1):
681
- _err("nothing removed")
682
- return 0
683
- except Refused as exc:
684
- _err(render.err(str(exc), RED))
685
- return 2
711
+ if not args.yes and not prune.confirm(1):
712
+ _err("nothing removed")
713
+ return 0
686
714
 
687
715
  keep_branch = chosen.verdict != prune.REMOVE
688
716
  # --force keeps the branch: the worktree was in the way, the work was not.
@@ -15,7 +15,6 @@ from __future__ import annotations
15
15
 
16
16
  import json
17
17
  import os
18
- import shutil
19
18
  import subprocess
20
19
  from dataclasses import dataclass
21
20
 
@@ -32,7 +31,14 @@ class Request:
32
31
 
33
32
 
34
33
  def available() -> str:
35
- """The forge CLI on PATH, or empty when there is none."""
34
+ """The forge CLI on PATH, or empty when there is none.
35
+
36
+ shutil costs 4.7 ms to import and one call needs it, so it is imported
37
+ here rather than at the top: every command that never asks the forge pays
38
+ nothing for the question.
39
+ """
40
+ import shutil
41
+
36
42
  for tool in ("gh", "glab"):
37
43
  if shutil.which(tool):
38
44
  return tool