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.
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.github/workflows/publish.yml +31 -7
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.github/workflows/tests.yml +7 -1
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.gitignore +3 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/AGENTS.md +22 -2
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/CHANGELOG.md +33 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/PKG-INFO +18 -4
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/README.md +16 -3
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwr.fish +1 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/docs/DEVELOPMENT.md +7 -0
- git_worktrees-0.2.3/hatch_build.py +49 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/pyproject.toml +16 -3
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/scripts/verify-publish.bash +4 -3
- git_worktrees-0.2.3/src/worktrees/__init__.py +15 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/cli.py +59 -31
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/forge.py +8 -2
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/git.py +89 -46
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/new_branch.py +10 -5
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/prune.py +36 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/repo.py +37 -25
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/rotate.py +6 -3
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/verdicts.py +5 -2
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/worktree.py +8 -5
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_cli.py +121 -1
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_gates.py +53 -2
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_shims.py +16 -3
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_version.py +30 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/uv.lock +36 -1
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwr +1 -0
- git_worktrees-0.2.1/src/worktrees/__init__.py +0 -8
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.agents/skills/release/SKILL.md +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.claude/settings.json +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.pre-commit-config.yaml +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/.python-version +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/LICENSE +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gw.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwa.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwh.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwl.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwm.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwnb.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwp.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gwrot.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/completions/gws.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/conf.d/worktrees.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwa.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwl.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwm.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/functions/gwr.fish +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/renovate.json +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/layout.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/merged.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/pick.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/src/worktrees/render.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/conftest.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_completions.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_forge.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_new_branch.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_render.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_rotate.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_spec.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/tests/test_worktree.py +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gw +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwa +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwh +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwl +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwm +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwnb +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwp +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gwrot +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/completions/_gws +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwa +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwl +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwm +0 -0
- {git_worktrees-0.2.1 → git_worktrees-0.2.3}/zsh/plugins/worktrees/functions/gwr +0 -0
- {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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
@@ -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
|
|
244
|
-
`branch -D`
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
15
|
-
#
|
|
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
|
-
#
|
|
43
|
-
# version resolves.
|
|
44
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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
|