git-worktrees 0.6.0__tar.gz → 0.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.github/workflows/publish.yml +18 -3
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.releasetools.yaml +3 -1
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/AGENTS.md +8 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/CHANGELOG.md +19 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/PKG-INFO +49 -7
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/README.md +48 -6
- git_worktrees-0.6.1/conf.d/worktrees.fish +7 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/docs/DEVELOPMENT.md +11 -2
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/hatch_build.py +2 -2
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/pyproject.toml +1 -1
- git_worktrees-0.6.1/scripts/bump_version.py +77 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/cli.py +59 -1
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_shims.py +36 -0
- git_worktrees-0.6.1/tests/test_version.py +233 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/uv.lock +1 -1
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/worktrees.plugin.zsh +6 -1
- git_worktrees-0.6.0/conf.d/worktrees.fish +0 -3
- git_worktrees-0.6.0/tests/test_version.py +0 -87
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.claude/settings.json +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.github/workflows/codeql.yml +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.github/workflows/testpypi.yml +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.github/workflows/tests.yml +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.gitignore +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.pre-commit-config.yaml +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/.python-version +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/LICENSE +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/Makefile +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gw.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwa.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwbc.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwbd.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwbs.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwh.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwl.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwm.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwp.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwr.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gwrot.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/gws.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/completions/worktrees.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/docs/BENCHMARK.md +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/docs/REFERENCE.md +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/docs/commands/branch-delete.md +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/docs/commands/branch-status.md +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/functions/gwa.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/functions/gwl.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/functions/gwm.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/functions/gwr!.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/functions/gwr.fish +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/renovate.json +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/scripts/README.md +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/scripts/benchmark.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/scripts/verify-publish.bash +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/__init__.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/branch_cli.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/branch_forge.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/branches.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/errors.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/forge.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/git.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/layout.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/merged.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/new_branch.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/pick.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/progress.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/prune.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/render.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/repo.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/rotate.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/verdicts.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/src/worktrees/worktree.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/conftest.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_benchmark.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_branch_forge.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_branches.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_cli.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_completions.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_fixture.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_forge.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_gates.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_new_branch.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_render.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_rotate.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_spec.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/tests/test_worktree.py +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gw +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwa +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwbc +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwbd +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwbs +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwh +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwl +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwm +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwp +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwr +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gwrot +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/completions/_gws +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/functions/gwa +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/functions/gwl +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/functions/gwm +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/functions/gwr +0 -0
- {git_worktrees-0.6.0 → git_worktrees-0.6.1}/zsh/plugins/worktrees/functions/gwr! +0 -0
|
@@ -192,12 +192,27 @@ jobs:
|
|
|
192
192
|
name: dist
|
|
193
193
|
path: dist/
|
|
194
194
|
|
|
195
|
-
# The install check
|
|
195
|
+
# The install check resolves the wheel alone, so the sdist can still be
|
|
196
|
+
# missing from PyPI here. A file PyPI does not serve yet is asked again,
|
|
197
|
+
# backing off from 15s over eight attempts as the install check does;
|
|
198
|
+
# any other failure ends the job at once.
|
|
196
199
|
- name: Verify attestations on PyPI
|
|
197
200
|
run: |
|
|
198
201
|
for f in dist/*.whl dist/*.tar.gz; do
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
file="${f#dist/}"
|
|
203
|
+
delay=15
|
|
204
|
+
for attempt in 1 2 3 4 5 6 7 8; do
|
|
205
|
+
code=0
|
|
206
|
+
uvx pypi-attestations@0.0.30 verify pypi \
|
|
207
|
+
--repository "https://github.com/$GITHUB_REPOSITORY" "pypi:$file" 2>err.txt || code=$?
|
|
208
|
+
cat err.txt >&2
|
|
209
|
+
[ "$code" -ne 0 ] || break
|
|
210
|
+
grep -q "Could not find the artifact" err.txt || exit 1
|
|
211
|
+
[ "$attempt" -lt 8 ] || exit 1
|
|
212
|
+
echo "waiting ${delay}s for PyPI to serve $file (attempt $attempt/8)..." >&2
|
|
213
|
+
sleep "$delay"
|
|
214
|
+
delay=$((delay * 2))
|
|
215
|
+
done
|
|
201
216
|
done
|
|
202
217
|
|
|
203
218
|
release:
|
|
@@ -10,7 +10,9 @@ projects:
|
|
|
10
10
|
- path: ./
|
|
11
11
|
manifest: pyproject.toml
|
|
12
12
|
changelog: CHANGELOG.md
|
|
13
|
-
|
|
13
|
+
# `uv version` for pyproject.toml and uv.lock, then the copies the shell
|
|
14
|
+
# plugins and the README's install lines carry.
|
|
15
|
+
bump: python3 scripts/bump_version.py {version}
|
|
14
16
|
|
|
15
17
|
release:
|
|
16
18
|
branch: main
|
|
@@ -105,6 +105,14 @@ zsh/plugins/worktrees/completions/_gwa
|
|
|
105
105
|
Neither manager puts a binary on `$PATH`. The console scripts arrive through
|
|
106
106
|
`uv tool install` from the published package, which is a separate step.
|
|
107
107
|
|
|
108
|
+
Both plugins export `WORKTREES_PLUGIN_VERSION`, the release they came from:
|
|
109
|
+
`conf.d/worktrees.fish` and `worktrees.plugin.zsh`, one assignment each.
|
|
110
|
+
`gwh` and `gw version` compare it with `__version__`, in Python; the shell
|
|
111
|
+
files hold the value and no logic. Nothing built can stamp a file a plugin
|
|
112
|
+
manager copies from git, so `scripts/bump_version.py` writes it at release,
|
|
113
|
+
along with the tag in the README's install lines, and `tests/test_version.py`
|
|
114
|
+
fails when a copy disagrees with `pyproject.toml`.
|
|
115
|
+
|
|
108
116
|
Nothing installs this from a checkout on the user's disk. It is consumed as a
|
|
109
117
|
third-party plugin and a third-party tool, so anything that assumes a local
|
|
110
118
|
clone at a known path is wrong here.
|
|
@@ -17,6 +17,25 @@ not in here, whatever it cost to build.
|
|
|
17
17
|
A release closes with a `### Choices` section when a decision in it is worth
|
|
18
18
|
the reader's time: what was chosen, and what the alternative failed to do.
|
|
19
19
|
|
|
20
|
+
## 0.6.1 - 2026-11-02
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
`gwh` says which release the shell plugin in this shell came from, and whether
|
|
25
|
+
it matches the commands. When the plugin is older, it names the tag to pin it
|
|
26
|
+
to, and when newer, it gives the `uv tool upgrade git-worktrees` to run.
|
|
27
|
+
`gw version` says the same on stderr, and its stdout is still the version
|
|
28
|
+
alone. Both plugins export `WORKTREES_PLUGIN_VERSION` for this. A shell whose
|
|
29
|
+
plugin predates this release reports none.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
The README installs the plugins at the release's tag, `fisher install
|
|
34
|
+
MihaiBojin/worktrees@v0.6.1` for fish and `branch:v0.6.1` for Antidote, and
|
|
35
|
+
says how to move each to another release. Moving an existing install onto a
|
|
36
|
+
tag takes an edit to `fish_plugins` and `fisher update`, or `antidote purge
|
|
37
|
+
MihaiBojin/worktrees` before editing the tag.
|
|
38
|
+
|
|
20
39
|
## 0.6.0 - 2026-11-02
|
|
21
40
|
|
|
22
41
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: git-worktrees
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Git worktree commands that refuse to lose work.
|
|
5
5
|
Project-URL: Bug reports, https://github.com/MihaiBojin/worktrees/issues/new
|
|
6
6
|
Project-URL: Source, https://github.com/MihaiBojin/worktrees
|
|
@@ -132,21 +132,35 @@ That installs `gws`, `gwp`, `gwbs`, `gwbd`, `gwbc`, `gwrot`, and `gwh`. The
|
|
|
132
132
|
four that land you somewhere need a shell function too, and every command has
|
|
133
133
|
a completion, both of which arrive through a plugin manager.
|
|
134
134
|
|
|
135
|
+
Install the plugin at the tag of the release `uv` installed. Without a tag
|
|
136
|
+
both managers take `main`, which can be ahead of the last release, and the
|
|
137
|
+
functions then call commands they were not tested against. `gwh` says which
|
|
138
|
+
release the plugin here came from, and whether it is the same one.
|
|
139
|
+
|
|
140
|
+
To upgrade, move both: `uv tool upgrade git-worktrees` for the commands, then
|
|
141
|
+
the plugin's tag to the same release, as each manager below describes.
|
|
142
|
+
|
|
135
143
|
### fish
|
|
136
144
|
|
|
137
145
|
```console
|
|
138
|
-
fisher install MihaiBojin/worktrees
|
|
146
|
+
fisher install MihaiBojin/worktrees@v0.6.1
|
|
139
147
|
```
|
|
140
148
|
|
|
141
|
-
Fisher copies `functions/` and `
|
|
142
|
-
|
|
149
|
+
Fisher copies `functions/`, `completions/` and `conf.d/` from the repository
|
|
150
|
+
root, so the functions for `gwa`, `gwl`, `gwm`, `gwr` and `gwr!` land together
|
|
151
|
+
with a completion for every command. Nothing has to be sourced.
|
|
152
|
+
|
|
153
|
+
To change release, edit the tag on that line of
|
|
154
|
+
`$__fish_config_dir/fish_plugins` and run `fisher update`. The same edit moves
|
|
155
|
+
an install without a tag onto one; `fisher install` with a tag refuses while
|
|
156
|
+
the untagged one holds the files.
|
|
143
157
|
|
|
144
158
|
### zsh
|
|
145
159
|
|
|
146
|
-
Antidote takes a `path:` into the repository:
|
|
160
|
+
Antidote takes a `path:` into the repository, and `branch:` takes a tag:
|
|
147
161
|
|
|
148
162
|
```
|
|
149
|
-
MihaiBojin/worktrees path:zsh/plugins/worktrees # in zsh_plugins.txt
|
|
163
|
+
MihaiBojin/worktrees branch:v0.6.1 path:zsh/plugins/worktrees # in zsh_plugins.txt
|
|
150
164
|
```
|
|
151
165
|
|
|
152
166
|
The plugin puts its own `functions/` and `completions/` on `fpath` and
|
|
@@ -157,6 +171,19 @@ run; without one, add it after the plugin loads:
|
|
|
157
171
|
autoload -Uz compinit && compinit
|
|
158
172
|
```
|
|
159
173
|
|
|
174
|
+
Antidote checks a tag out only when it clones, and `antidote update` leaves a
|
|
175
|
+
tag where it is. To change release, remove the clone, then edit the tag, then
|
|
176
|
+
open a new shell, which clones it again at the new tag:
|
|
177
|
+
|
|
178
|
+
```zsh
|
|
179
|
+
antidote purge MihaiBojin/worktrees
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The order matters. Antidote rebuilds its bundle only when `zsh_plugins.txt` is
|
|
183
|
+
newer than the bundle, so a tag edited before the purge leaves a new shell
|
|
184
|
+
with no plugin until the file changes again. The same three steps move a clone
|
|
185
|
+
without a tag onto one.
|
|
186
|
+
|
|
160
187
|
### What the shell code does
|
|
161
188
|
|
|
162
189
|
Each function runs `command <name>` and `cd`s to what it printed, provided
|
|
@@ -166,6 +193,16 @@ somewhere to go and stays put when it is not. `gws`, `gwp`, `gwbc`, `gwrot` and
|
|
|
166
193
|
change no directory, so they ship as console scripts alone and answer the same
|
|
167
194
|
from a prompt and from a script.
|
|
168
195
|
|
|
196
|
+
Both plugins export `WORKTREES_PLUGIN_VERSION`, the release they came from,
|
|
197
|
+
since the environment is all a binary sees of the shell that started it. `gwh`
|
|
198
|
+
and `gw version` compare it with their own and say whether the two match,
|
|
199
|
+
which is older, and what to run: `gwh` in its text, `gw version` on stderr, so
|
|
200
|
+
its stdout stays the version alone for a script to read.
|
|
201
|
+
|
|
202
|
+
A shell without the variable has no plugin loaded, or one older than the
|
|
203
|
+
release that started exporting it, and nothing can tell those two apart. A
|
|
204
|
+
bash started from fish or zsh inherits the variable without the functions.
|
|
205
|
+
|
|
169
206
|
The completions hold no candidate of their own. `gw --complete` prints every
|
|
170
207
|
subcommand and shorthand, `gwl --complete` prints every worktree, and both
|
|
171
208
|
shells narrow what those return, so the matching stays in one place and a
|
|
@@ -495,7 +532,7 @@ their own and `gwbc` puts them on a branch.
|
|
|
495
532
|
|
|
496
533
|
```console
|
|
497
534
|
$ gwh
|
|
498
|
-
worktrees 0.
|
|
535
|
+
worktrees 0.6.1: git worktree commands that refuse to lose work
|
|
499
536
|
|
|
500
537
|
COMMAND GW DOES
|
|
501
538
|
gws gw status (s, st) which worktrees are finished, and why
|
|
@@ -510,6 +547,11 @@ gwh gw help (h) every command and alias, this list
|
|
|
510
547
|
gwbc NAME gw branch create branch off the head branch
|
|
511
548
|
gwbs [NAME ...] gw branch status which branches are finished, and why
|
|
512
549
|
gwbd [NAME ...] gw branch delete delete local branches proved finished
|
|
550
|
+
|
|
551
|
+
gwa, gwl, gwm and gwr change the directory you are standing in.
|
|
552
|
+
A binary cannot do that, so each needs the shell function of the same name
|
|
553
|
+
from the plugin; the rest need nothing but $PATH.
|
|
554
|
+
The shell plugin here is 0.6.1, the same release as these commands.
|
|
513
555
|
```
|
|
514
556
|
|
|
515
557
|
`gw help`, `gw h` and `gw` with no subcommand print the same thing. One table
|
|
@@ -106,21 +106,35 @@ That installs `gws`, `gwp`, `gwbs`, `gwbd`, `gwbc`, `gwrot`, and `gwh`. The
|
|
|
106
106
|
four that land you somewhere need a shell function too, and every command has
|
|
107
107
|
a completion, both of which arrive through a plugin manager.
|
|
108
108
|
|
|
109
|
+
Install the plugin at the tag of the release `uv` installed. Without a tag
|
|
110
|
+
both managers take `main`, which can be ahead of the last release, and the
|
|
111
|
+
functions then call commands they were not tested against. `gwh` says which
|
|
112
|
+
release the plugin here came from, and whether it is the same one.
|
|
113
|
+
|
|
114
|
+
To upgrade, move both: `uv tool upgrade git-worktrees` for the commands, then
|
|
115
|
+
the plugin's tag to the same release, as each manager below describes.
|
|
116
|
+
|
|
109
117
|
### fish
|
|
110
118
|
|
|
111
119
|
```console
|
|
112
|
-
fisher install MihaiBojin/worktrees
|
|
120
|
+
fisher install MihaiBojin/worktrees@v0.6.1
|
|
113
121
|
```
|
|
114
122
|
|
|
115
|
-
Fisher copies `functions/` and `
|
|
116
|
-
|
|
123
|
+
Fisher copies `functions/`, `completions/` and `conf.d/` from the repository
|
|
124
|
+
root, so the functions for `gwa`, `gwl`, `gwm`, `gwr` and `gwr!` land together
|
|
125
|
+
with a completion for every command. Nothing has to be sourced.
|
|
126
|
+
|
|
127
|
+
To change release, edit the tag on that line of
|
|
128
|
+
`$__fish_config_dir/fish_plugins` and run `fisher update`. The same edit moves
|
|
129
|
+
an install without a tag onto one; `fisher install` with a tag refuses while
|
|
130
|
+
the untagged one holds the files.
|
|
117
131
|
|
|
118
132
|
### zsh
|
|
119
133
|
|
|
120
|
-
Antidote takes a `path:` into the repository:
|
|
134
|
+
Antidote takes a `path:` into the repository, and `branch:` takes a tag:
|
|
121
135
|
|
|
122
136
|
```
|
|
123
|
-
MihaiBojin/worktrees path:zsh/plugins/worktrees # in zsh_plugins.txt
|
|
137
|
+
MihaiBojin/worktrees branch:v0.6.1 path:zsh/plugins/worktrees # in zsh_plugins.txt
|
|
124
138
|
```
|
|
125
139
|
|
|
126
140
|
The plugin puts its own `functions/` and `completions/` on `fpath` and
|
|
@@ -131,6 +145,19 @@ run; without one, add it after the plugin loads:
|
|
|
131
145
|
autoload -Uz compinit && compinit
|
|
132
146
|
```
|
|
133
147
|
|
|
148
|
+
Antidote checks a tag out only when it clones, and `antidote update` leaves a
|
|
149
|
+
tag where it is. To change release, remove the clone, then edit the tag, then
|
|
150
|
+
open a new shell, which clones it again at the new tag:
|
|
151
|
+
|
|
152
|
+
```zsh
|
|
153
|
+
antidote purge MihaiBojin/worktrees
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The order matters. Antidote rebuilds its bundle only when `zsh_plugins.txt` is
|
|
157
|
+
newer than the bundle, so a tag edited before the purge leaves a new shell
|
|
158
|
+
with no plugin until the file changes again. The same three steps move a clone
|
|
159
|
+
without a tag onto one.
|
|
160
|
+
|
|
134
161
|
### What the shell code does
|
|
135
162
|
|
|
136
163
|
Each function runs `command <name>` and `cd`s to what it printed, provided
|
|
@@ -140,6 +167,16 @@ somewhere to go and stays put when it is not. `gws`, `gwp`, `gwbc`, `gwrot` and
|
|
|
140
167
|
change no directory, so they ship as console scripts alone and answer the same
|
|
141
168
|
from a prompt and from a script.
|
|
142
169
|
|
|
170
|
+
Both plugins export `WORKTREES_PLUGIN_VERSION`, the release they came from,
|
|
171
|
+
since the environment is all a binary sees of the shell that started it. `gwh`
|
|
172
|
+
and `gw version` compare it with their own and say whether the two match,
|
|
173
|
+
which is older, and what to run: `gwh` in its text, `gw version` on stderr, so
|
|
174
|
+
its stdout stays the version alone for a script to read.
|
|
175
|
+
|
|
176
|
+
A shell without the variable has no plugin loaded, or one older than the
|
|
177
|
+
release that started exporting it, and nothing can tell those two apart. A
|
|
178
|
+
bash started from fish or zsh inherits the variable without the functions.
|
|
179
|
+
|
|
143
180
|
The completions hold no candidate of their own. `gw --complete` prints every
|
|
144
181
|
subcommand and shorthand, `gwl --complete` prints every worktree, and both
|
|
145
182
|
shells narrow what those return, so the matching stays in one place and a
|
|
@@ -469,7 +506,7 @@ their own and `gwbc` puts them on a branch.
|
|
|
469
506
|
|
|
470
507
|
```console
|
|
471
508
|
$ gwh
|
|
472
|
-
worktrees 0.
|
|
509
|
+
worktrees 0.6.1: git worktree commands that refuse to lose work
|
|
473
510
|
|
|
474
511
|
COMMAND GW DOES
|
|
475
512
|
gws gw status (s, st) which worktrees are finished, and why
|
|
@@ -484,6 +521,11 @@ gwh gw help (h) every command and alias, this list
|
|
|
484
521
|
gwbc NAME gw branch create branch off the head branch
|
|
485
522
|
gwbs [NAME ...] gw branch status which branches are finished, and why
|
|
486
523
|
gwbd [NAME ...] gw branch delete delete local branches proved finished
|
|
524
|
+
|
|
525
|
+
gwa, gwl, gwm and gwr change the directory you are standing in.
|
|
526
|
+
A binary cannot do that, so each needs the shell function of the same name
|
|
527
|
+
from the plugin; the rest need nothing but $PATH.
|
|
528
|
+
The shell plugin here is 0.6.1, the same release as these commands.
|
|
487
529
|
```
|
|
488
530
|
|
|
489
531
|
`gw help`, `gw h` and `gw` with no subcommand print the same thing. One table
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Fisher autoloads functions/ and completions/ on demand, and the binaries
|
|
2
|
+
# come from `uv tool install` rather than from this package.
|
|
3
|
+
#
|
|
4
|
+
# The release this plugin came from, which `gwh` and `gw version` compare
|
|
5
|
+
# with their own. Exported, because the environment is all a binary can see
|
|
6
|
+
# of the shell that started it.
|
|
7
|
+
set -gx WORKTREES_PLUGIN_VERSION 0.6.1
|
|
@@ -157,7 +157,7 @@ network. `GIT_CONFIG_GLOBAL` carries that: without it git still reads
|
|
|
157
157
|
```
|
|
158
158
|
|
|
159
159
|
That does the whole of it: cut `release/v0.2.0` from `origin/main`,
|
|
160
|
-
`
|
|
160
|
+
`scripts/bump_version.py 0.2.0`, open the pull request, wait for its checks, squash-merge,
|
|
161
161
|
wait for main's own run on the merged commit, and only then tag. The tag lands
|
|
162
162
|
on a commit already proved green, so it never has to move.
|
|
163
163
|
|
|
@@ -201,13 +201,22 @@ published. Nothing is generated from pull request titles.
|
|
|
201
201
|
By hand it is the same four commands:
|
|
202
202
|
|
|
203
203
|
```console
|
|
204
|
-
|
|
204
|
+
python3 scripts/bump_version.py 0.2.0 # uv version, then the plugins and README
|
|
205
205
|
# open a pull request, review it, merge it
|
|
206
206
|
git switch main && git pull
|
|
207
207
|
git tag -a v0.2.0 -m v0.2.0
|
|
208
208
|
git push origin v0.2.0
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
+
`uv version` alone is not the bump. It rewrites `pyproject.toml` and
|
|
212
|
+
`uv.lock`, and the shell plugins carry a copy it never sees: Fisher and
|
|
213
|
+
Antidote install them from the tag rather than from anything built, so each
|
|
214
|
+
exports `WORKTREES_PLUGIN_VERSION` written into it, and the README's install
|
|
215
|
+
lines name the tag. `scripts/bump_version.py` writes every one of them, and
|
|
216
|
+
checks each line matches before it writes any. `tests/test_version.py` fails
|
|
217
|
+
when one disagrees with `pyproject.toml`, so a release pull request missing
|
|
218
|
+
one fails its checks before anything is tagged.
|
|
219
|
+
|
|
211
220
|
The version lives in `pyproject.toml`, in the repository, on every commit,
|
|
212
221
|
and in git history. The tag agrees with it or nothing is published. A tag
|
|
213
222
|
moved onto a commit carrying a different version is refused, which is the
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Reading it back through `importlib.metadata` at import instead costs 10 ms of
|
|
4
4
|
a 67 ms invocation, on every command, for a string two of them print. A
|
|
5
|
-
constant in the wheel costs nothing, and
|
|
6
|
-
|
|
5
|
+
constant in the wheel costs nothing, and the package reads its version from
|
|
6
|
+
`pyproject.toml` alone. The shell plugins' copy is `scripts/bump_version.py`'s.
|
|
7
7
|
|
|
8
8
|
Not `hatch-vcs`: deriving the version from a tag inverts which of the two has
|
|
9
9
|
to agree with the other, and `publish.yml` refuses a tag whose commit carries
|
|
@@ -9,7 +9,7 @@ authors = [
|
|
|
9
9
|
]
|
|
10
10
|
# Static, and the single source of truth for the version. Bump it in a pull
|
|
11
11
|
# request and run `uv lock`; the release tag only has to agree with it.
|
|
12
|
-
version = "0.6.
|
|
12
|
+
version = "0.6.1"
|
|
13
13
|
# The standard library answers every question this asks. A dependency here
|
|
14
14
|
# would be paid on every invocation, and an invocation is 67 ms of which 25 ms
|
|
15
15
|
# is the interpreter starting.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Set the release version in every file that names it.
|
|
2
|
+
|
|
3
|
+
python3 scripts/bump_version.py 0.7.0
|
|
4
|
+
|
|
5
|
+
`uv version` writes `pyproject.toml` and `uv.lock`, and the wheel reads it
|
|
6
|
+
from there. The shell plugins carry a copy of their own, because Fisher and
|
|
7
|
+
Antidote install them from a git tag rather than from anything built, and the
|
|
8
|
+
README's install lines name that tag. `.releasetools.yaml` runs this as the
|
|
9
|
+
release's bump, and `tests/test_version.py` fails when a copy disagrees with
|
|
10
|
+
`pyproject.toml`.
|
|
11
|
+
|
|
12
|
+
Every line is checked before anything is written: a line that no longer
|
|
13
|
+
matches stops the bump with nothing changed, rather than releasing a version
|
|
14
|
+
some copy never heard of.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
25
|
+
|
|
26
|
+
# Each file, and the lines in it that name the release, `{}` where it goes.
|
|
27
|
+
STAMPS: dict[str, tuple[str, ...]] = {
|
|
28
|
+
"conf.d/worktrees.fish": ("set -gx WORKTREES_PLUGIN_VERSION {}",),
|
|
29
|
+
"zsh/plugins/worktrees/worktrees.plugin.zsh": (
|
|
30
|
+
"export WORKTREES_PLUGIN_VERSION={}",
|
|
31
|
+
),
|
|
32
|
+
"README.md": (
|
|
33
|
+
"MihaiBojin/worktrees@v{}",
|
|
34
|
+
"MihaiBojin/worktrees branch:v{}",
|
|
35
|
+
"worktrees {}: git worktree commands",
|
|
36
|
+
"The shell plugin here is {}, the same release",
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pattern(template: str) -> re.Pattern[str]:
|
|
42
|
+
"""The template with any version where `{}` is."""
|
|
43
|
+
before, after = (re.escape(part) for part in template.split("{}"))
|
|
44
|
+
return re.compile(before + r"[0-9][0-9A-Za-z.+]*" + after)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def stamped(root: Path, version: str) -> dict[Path, str]:
|
|
48
|
+
"""Every file's text with `version` written in, or SystemExit naming the
|
|
49
|
+
line that matched nothing."""
|
|
50
|
+
texts = {}
|
|
51
|
+
for name, templates in STAMPS.items():
|
|
52
|
+
path = root / name
|
|
53
|
+
text = path.read_text()
|
|
54
|
+
for template in templates:
|
|
55
|
+
# subn reads a backslash in the replacement as a group reference.
|
|
56
|
+
line = template.format(version).replace("\\", r"\\")
|
|
57
|
+
text, count = _pattern(template).subn(line, text)
|
|
58
|
+
if not count:
|
|
59
|
+
raise SystemExit(f"{name}: no line matches {template!r}")
|
|
60
|
+
texts[path] = text
|
|
61
|
+
return texts
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str]) -> int:
|
|
65
|
+
if len(argv) != 1:
|
|
66
|
+
print("usage: bump_version.py VERSION", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
(version,) = argv
|
|
69
|
+
texts = stamped(ROOT, version)
|
|
70
|
+
subprocess.run(["uv", "version", version], cwd=ROOT, check=True)
|
|
71
|
+
for path, text in texts.items():
|
|
72
|
+
path.write_text(text)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import os
|
|
6
7
|
import sys
|
|
7
8
|
import time
|
|
8
9
|
from collections.abc import Callable
|
|
@@ -1059,9 +1060,64 @@ def run_remove(args: argparse.Namespace) -> int:
|
|
|
1059
1060
|
# --------------------------------------------------------------------------
|
|
1060
1061
|
|
|
1061
1062
|
|
|
1063
|
+
# Exported by conf.d/worktrees.fish and worktrees.plugin.zsh, set to the
|
|
1064
|
+
# release each came from. scripts/bump_version.py writes both.
|
|
1065
|
+
PLUGIN_VERSION = "WORKTREES_PLUGIN_VERSION"
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _release(version: str) -> tuple[int, ...] | None:
|
|
1069
|
+
"""`x.y.z` as numbers to compare, or None for anything else, such as the
|
|
1070
|
+
`0+unknown` of a source tree nothing installed."""
|
|
1071
|
+
parts = version.split(".")
|
|
1072
|
+
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
|
1073
|
+
return None
|
|
1074
|
+
return tuple(int(p) for p in parts)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def plugin_state() -> tuple[str, bool]:
|
|
1078
|
+
"""What the shell plugin here says about its release, against this
|
|
1079
|
+
package's, and whether the two are the same release.
|
|
1080
|
+
|
|
1081
|
+
A shell without the variable has no plugin loaded, or one from before the
|
|
1082
|
+
plugin carried it. Nothing here can tell those two apart.
|
|
1083
|
+
"""
|
|
1084
|
+
plugin = os.environ.get(PLUGIN_VERSION, "")
|
|
1085
|
+
if not plugin:
|
|
1086
|
+
return (
|
|
1087
|
+
"No shell plugin here says which release it is: none is loaded,\n"
|
|
1088
|
+
"or it is older than the release that started saying so.",
|
|
1089
|
+
False,
|
|
1090
|
+
)
|
|
1091
|
+
if plugin == __version__:
|
|
1092
|
+
return (
|
|
1093
|
+
f"The shell plugin here is {plugin}, the same release as these commands.",
|
|
1094
|
+
True,
|
|
1095
|
+
)
|
|
1096
|
+
ours, theirs = _release(__version__), _release(plugin)
|
|
1097
|
+
if ours and theirs and theirs < ours:
|
|
1098
|
+
return (
|
|
1099
|
+
f"The shell plugin here is {plugin}, older than these commands.\n"
|
|
1100
|
+
f"Pin it to v{__version__}: https://github.com/MihaiBojin/worktrees#install",
|
|
1101
|
+
False,
|
|
1102
|
+
)
|
|
1103
|
+
if ours and theirs:
|
|
1104
|
+
return (
|
|
1105
|
+
f"The shell plugin here is {plugin}, newer than these commands.\n"
|
|
1106
|
+
"Upgrade them with `uv tool upgrade git-worktrees`.",
|
|
1107
|
+
False,
|
|
1108
|
+
)
|
|
1109
|
+
return (
|
|
1110
|
+
f"The shell plugin here is {plugin}, and these commands are {__version__}.",
|
|
1111
|
+
False,
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
|
|
1062
1115
|
def run_version(args: argparse.Namespace) -> int:
|
|
1063
|
-
"""The version of the installed distribution
|
|
1116
|
+
"""The version of the installed distribution on stdout, which is what a
|
|
1117
|
+
script reads, and the shell plugin's on stderr."""
|
|
1064
1118
|
print(__version__)
|
|
1119
|
+
text, same = plugin_state()
|
|
1120
|
+
_err(render.note(text) if same else render.warn(text))
|
|
1065
1121
|
return 0
|
|
1066
1122
|
|
|
1067
1123
|
|
|
@@ -1109,6 +1165,8 @@ def run_help(args: argparse.Namespace) -> int:
|
|
|
1109
1165
|
"standing in.\nA binary cannot do that, so each needs the shell function "
|
|
1110
1166
|
"of the same name\nfrom the plugin; the rest need nothing but $PATH."
|
|
1111
1167
|
)
|
|
1168
|
+
text, same = plugin_state()
|
|
1169
|
+
print(o(text, "" if same else YELLOW))
|
|
1112
1170
|
print()
|
|
1113
1171
|
# Which commands take the common flags is the table's answer, not a second
|
|
1114
1172
|
# list here: a row with no flags takes none, and saying so in prose is how
|
|
@@ -9,8 +9,10 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
11
|
import pty
|
|
12
|
+
import shlex
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
15
|
+
import sys
|
|
14
16
|
import tomllib
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from typing import NoReturn
|
|
@@ -323,6 +325,40 @@ def test_the_zsh_shim_prints_output_that_is_not_a_directory(tmp_path) -> None:
|
|
|
323
325
|
assert out.splitlines() == [payload, "/"], out
|
|
324
326
|
|
|
325
327
|
|
|
328
|
+
@pytest.mark.parametrize(
|
|
329
|
+
("shell", "plugin"),
|
|
330
|
+
[
|
|
331
|
+
("fish", ROOT / "conf.d" / "worktrees.fish"),
|
|
332
|
+
("zsh", ROOT / "zsh/plugins/worktrees/worktrees.plugin.zsh"),
|
|
333
|
+
],
|
|
334
|
+
)
|
|
335
|
+
def test_each_plugin_tells_the_binary_its_release(shell: str, plugin: Path) -> None:
|
|
336
|
+
"""Through the environment, so only an exported variable arrives: a fish
|
|
337
|
+
`set -g` or a bare zsh assignment stays in the shell, and the binary then
|
|
338
|
+
reports no plugin at all."""
|
|
339
|
+
binary = shutil.which(shell)
|
|
340
|
+
if not binary: # pragma: no cover
|
|
341
|
+
_missing(shell)
|
|
342
|
+
gw = shlex.join(
|
|
343
|
+
[
|
|
344
|
+
sys.executable,
|
|
345
|
+
"-c",
|
|
346
|
+
"from worktrees.cli import gw; raise SystemExit(gw())",
|
|
347
|
+
"version",
|
|
348
|
+
]
|
|
349
|
+
)
|
|
350
|
+
no_config = "--no-config" if shell == "fish" else "-f"
|
|
351
|
+
env = {k: v for k, v in os.environ.items() if k != "WORKTREES_PLUGIN_VERSION"}
|
|
352
|
+
proc = subprocess.run(
|
|
353
|
+
[binary, no_config, "-c", f"source {shlex.quote(str(plugin))}; {gw}"],
|
|
354
|
+
capture_output=True,
|
|
355
|
+
text=True,
|
|
356
|
+
env=env,
|
|
357
|
+
)
|
|
358
|
+
assert proc.returncode == 0, proc.stderr
|
|
359
|
+
assert "the same release as these commands" in proc.stderr, proc.stderr
|
|
360
|
+
|
|
361
|
+
|
|
326
362
|
# --------------------------------------------------------------------------
|
|
327
363
|
# a line printed to be pasted lands in whichever shell the reader has
|
|
328
364
|
# --------------------------------------------------------------------------
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""`pyproject.toml` declares the version and `gw version` reads that one. The
|
|
2
|
+
shell plugins and the README's install lines carry copies, which
|
|
3
|
+
`scripts/bump_version.py` writes and these hold to it."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(ROOT))
|
|
18
|
+
sys.path.insert(0, str(ROOT / "scripts"))
|
|
19
|
+
|
|
20
|
+
import bump_version # noqa: E402
|
|
21
|
+
|
|
22
|
+
from worktrees import cli # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _declared() -> str:
|
|
26
|
+
return str(
|
|
27
|
+
tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_the_package_declares_its_version_nowhere() -> None:
|
|
32
|
+
"""A second copy is a second thing to bump, and `uv version` bumps one.
|
|
33
|
+
|
|
34
|
+
`uv version 0.2.0` rewrites pyproject.toml and uv.lock. It does not touch
|
|
35
|
+
Python source, so a literal here would survive a release and every
|
|
36
|
+
console script would report the version before it.
|
|
37
|
+
"""
|
|
38
|
+
source = (ROOT / "src" / "worktrees" / "__init__.py").read_text()
|
|
39
|
+
declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
|
|
40
|
+
"version"
|
|
41
|
+
]
|
|
42
|
+
assert declared not in source, (
|
|
43
|
+
f"{declared} is written into __init__.py as well as pyproject.toml"
|
|
44
|
+
)
|
|
45
|
+
assert 'version("git-worktrees")' in source
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_version_matches_the_distribution() -> None:
|
|
49
|
+
from worktrees import __version__
|
|
50
|
+
|
|
51
|
+
declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
|
|
52
|
+
"version"
|
|
53
|
+
]
|
|
54
|
+
assert __version__ == declared
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_both_names_that_take_a_subcommand_report_it() -> None:
|
|
58
|
+
"""Driven, because the console script is what a person runs."""
|
|
59
|
+
declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
|
|
60
|
+
"version"
|
|
61
|
+
]
|
|
62
|
+
for entry in ("gw", "main"):
|
|
63
|
+
proc = subprocess.run(
|
|
64
|
+
[
|
|
65
|
+
sys.executable,
|
|
66
|
+
"-c",
|
|
67
|
+
f"from worktrees.cli import {entry}; raise SystemExit({entry}())",
|
|
68
|
+
"version",
|
|
69
|
+
],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
assert proc.returncode == 0, proc.stderr
|
|
75
|
+
assert proc.stdout.strip() == declared, entry
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_the_build_hook_writes_and_ships_the_version(tmp_path) -> None:
|
|
79
|
+
"""Both halves, because either one alone fails in silence.
|
|
80
|
+
|
|
81
|
+
`.gitignore` keeps the generated file out of the repository, and hatchling
|
|
82
|
+
reads `.gitignore`, so a hook that only writes it builds a wheel without
|
|
83
|
+
it. Every install then falls back to the metadata lookup and nothing says
|
|
84
|
+
so.
|
|
85
|
+
"""
|
|
86
|
+
import hatch_build
|
|
87
|
+
|
|
88
|
+
assert hatch_build.GENERATED.as_posix() in (ROOT / ".gitignore").read_text()
|
|
89
|
+
|
|
90
|
+
(tmp_path / hatch_build.GENERATED.parent).mkdir(parents=True)
|
|
91
|
+
build_data: dict[str, list[str]] = {}
|
|
92
|
+
written = hatch_build.generate(tmp_path, "9.9.9", build_data)
|
|
93
|
+
|
|
94
|
+
namespace: dict[str, str] = {}
|
|
95
|
+
exec(written.read_text(), namespace)
|
|
96
|
+
assert namespace["__version__"] == "9.9.9"
|
|
97
|
+
assert f"/{hatch_build.GENERATED.as_posix()}" in build_data["artifacts"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_a_checkout_falls_back_to_the_metadata_lookup() -> None:
|
|
101
|
+
"""No generated file is the normal state of a clone, and it still answers."""
|
|
102
|
+
source = (ROOT / "src" / "worktrees" / "__init__.py").read_text()
|
|
103
|
+
assert "from ._version import __version__" in source
|
|
104
|
+
assert "except ImportError" in source
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
# the copies the shell plugins carry
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize(
|
|
113
|
+
("name", "template"),
|
|
114
|
+
[(n, t) for n, ts in bump_version.STAMPS.items() for t in ts],
|
|
115
|
+
)
|
|
116
|
+
def test_every_copy_names_the_declared_release(name: str, template: str) -> None:
|
|
117
|
+
"""A release pull request whose bump missed a copy fails here, before
|
|
118
|
+
anything is tagged."""
|
|
119
|
+
assert template.format(_declared()) in (ROOT / name).read_text()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _copy(tmp_path: Path) -> Path:
|
|
123
|
+
"""The script and the files it stamps, laid out as the repository has them."""
|
|
124
|
+
for name in [*bump_version.STAMPS, "scripts/bump_version.py"]:
|
|
125
|
+
(tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
shutil.copy(ROOT / name, tmp_path / name)
|
|
127
|
+
return tmp_path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _bump(root: Path, version: str) -> tuple[subprocess.CompletedProcess[str], Path]:
|
|
131
|
+
"""Run the copied script with a `uv` that records what it was asked."""
|
|
132
|
+
seen = root / "uv-argv"
|
|
133
|
+
fake = root / "bin" / "uv"
|
|
134
|
+
fake.parent.mkdir()
|
|
135
|
+
fake.write_text(f'#!/bin/sh\necho "$PWD $*" > "{seen}"\n')
|
|
136
|
+
fake.chmod(0o755)
|
|
137
|
+
proc = subprocess.run(
|
|
138
|
+
[sys.executable, str(root / "scripts" / "bump_version.py"), version],
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
env={**os.environ, "PATH": f"{fake.parent}{os.pathsep}{os.environ['PATH']}"},
|
|
142
|
+
)
|
|
143
|
+
return proc, seen
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_the_bump_writes_every_copy_and_runs_uv_version(tmp_path) -> None:
|
|
147
|
+
root = _copy(tmp_path)
|
|
148
|
+
proc, seen = _bump(root, "9.9.9")
|
|
149
|
+
assert proc.returncode == 0, proc.stderr
|
|
150
|
+
assert seen.read_text().split() == [str(root.resolve()), "version", "9.9.9"]
|
|
151
|
+
for name, templates in bump_version.STAMPS.items():
|
|
152
|
+
text = (root / name).read_text()
|
|
153
|
+
for template in templates:
|
|
154
|
+
assert template.format("9.9.9") in text, (name, template)
|
|
155
|
+
assert template.format(_declared()) not in text, (name, template)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_a_line_the_bump_cannot_find_changes_nothing(tmp_path) -> None:
|
|
159
|
+
"""Checked before anything is written, so a renamed variable stops the
|
|
160
|
+
release with every file as it was rather than half of them bumped."""
|
|
161
|
+
root = _copy(tmp_path)
|
|
162
|
+
zsh = root / "zsh/plugins/worktrees/worktrees.plugin.zsh"
|
|
163
|
+
zsh.write_text(zsh.read_text().replace("WORKTREES_PLUGIN_VERSION", "RENAMED"))
|
|
164
|
+
before = {name: (root / name).read_text() for name in bump_version.STAMPS}
|
|
165
|
+
|
|
166
|
+
proc, seen = _bump(root, "9.9.9")
|
|
167
|
+
|
|
168
|
+
assert proc.returncode != 0
|
|
169
|
+
assert "export WORKTREES_PLUGIN_VERSION" in proc.stderr
|
|
170
|
+
assert not seen.exists()
|
|
171
|
+
assert {name: (root / name).read_text() for name in bump_version.STAMPS} == before
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
# what gwh and gw version say about the plugin
|
|
176
|
+
# --------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.parametrize(
|
|
180
|
+
("plugin", "same", "says"),
|
|
181
|
+
[
|
|
182
|
+
("0.6.0", True, "the same release as these commands"),
|
|
183
|
+
("0.5.1", False, "older than these commands.\nPin it to v0.6.0"),
|
|
184
|
+
("0.10.0", False, "newer than these commands.\nUpgrade them"),
|
|
185
|
+
("0.6.0.post1", False, "and these commands are 0.6.0"),
|
|
186
|
+
("", False, "No shell plugin here says which release it is"),
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
def test_the_plugin_is_compared_with_these_commands(
|
|
190
|
+
monkeypatch, plugin: str, same: bool, says: str
|
|
191
|
+
) -> None:
|
|
192
|
+
"""0.10.0 is newer than 0.6.0, which a string comparison gets backwards.
|
|
193
|
+
Anything that is not `x.y.z` is printed beside ours and not ranked."""
|
|
194
|
+
monkeypatch.setattr(cli, "__version__", "0.6.0")
|
|
195
|
+
monkeypatch.setenv(cli.PLUGIN_VERSION, plugin)
|
|
196
|
+
text, matched = cli.plugin_state()
|
|
197
|
+
assert matched is same
|
|
198
|
+
assert says in text
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _gw(*args: str, plugin: str | None) -> subprocess.CompletedProcess[str]:
|
|
202
|
+
env = {k: v for k, v in os.environ.items() if k != cli.PLUGIN_VERSION}
|
|
203
|
+
if plugin is not None:
|
|
204
|
+
env[cli.PLUGIN_VERSION] = plugin
|
|
205
|
+
return subprocess.run(
|
|
206
|
+
[
|
|
207
|
+
sys.executable,
|
|
208
|
+
"-c",
|
|
209
|
+
"from worktrees.cli import gw; raise SystemExit(gw())",
|
|
210
|
+
*args,
|
|
211
|
+
],
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
env=env,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@pytest.mark.parametrize("plugin", [None, "0.0.1", "99.0.0", _declared()])
|
|
219
|
+
def test_gw_version_keeps_stdout_to_the_version(plugin: str | None) -> None:
|
|
220
|
+
"""`scripts/verify-publish.bash` compares stdout with the version it asked
|
|
221
|
+
the index for, so what the plugin says goes to stderr."""
|
|
222
|
+
proc = _gw("version", plugin=plugin)
|
|
223
|
+
assert proc.returncode == 0, proc.stderr
|
|
224
|
+
assert proc.stdout == f"{_declared()}\n"
|
|
225
|
+
assert "shell plugin" in proc.stderr
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@pytest.mark.parametrize("plugin", [None, "0.0.1", "99.0.0", "0.6.0.post1789247568"])
|
|
229
|
+
def test_gwh_says_what_the_plugin_is_inside_eighty_columns(plugin: str | None) -> None:
|
|
230
|
+
proc = _gw("help", plugin=plugin)
|
|
231
|
+
assert proc.returncode == 0, proc.stderr
|
|
232
|
+
assert "shell plugin" in proc.stdout
|
|
233
|
+
assert [ln for ln in proc.stdout.splitlines() if len(ln) > 80] == []
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Everything else here is a console script and needs nothing: gws, gwp, gwbc
|
|
5
5
|
# and gwrot answer the same from a prompt and from a script. A binary cannot
|
|
6
|
-
# cd its caller, and that is the
|
|
6
|
+
# cd its caller, and that is the reason this file exists.
|
|
7
7
|
#
|
|
8
8
|
# $PATH is the package's business. `uv tool install` puts the binaries in
|
|
9
9
|
# ~/.local/bin; nothing here adds a directory to it.
|
|
@@ -13,3 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
fpath+=("${0:h}/functions" "${0:h}/completions")
|
|
15
15
|
autoload -Uz gwa gwl gwm gwr 'gwr!'
|
|
16
|
+
|
|
17
|
+
# The release this plugin came from, which `gwh` and `gw version` compare
|
|
18
|
+
# with their own. Exported, because the environment is all a binary can see
|
|
19
|
+
# of the shell that started it.
|
|
20
|
+
export WORKTREES_PLUGIN_VERSION=0.6.1
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"""The version is written once, and `gw version` reads that one."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
import tomllib
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
-
sys.path.insert(0, str(ROOT))
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_the_package_declares_its_version_nowhere() -> None:
|
|
15
|
-
"""A second copy is a second thing to bump, and `uv version` bumps one.
|
|
16
|
-
|
|
17
|
-
`uv version 0.2.0` rewrites pyproject.toml and uv.lock. It does not touch
|
|
18
|
-
Python source, so a literal here would survive a release and every
|
|
19
|
-
console script would report the version before it.
|
|
20
|
-
"""
|
|
21
|
-
source = (ROOT / "src" / "worktrees" / "__init__.py").read_text()
|
|
22
|
-
declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
|
|
23
|
-
"version"
|
|
24
|
-
]
|
|
25
|
-
assert declared not in source, (
|
|
26
|
-
f"{declared} is written into __init__.py as well as pyproject.toml"
|
|
27
|
-
)
|
|
28
|
-
assert 'version("git-worktrees")' in source
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_version_matches_the_distribution() -> None:
|
|
32
|
-
from worktrees import __version__
|
|
33
|
-
|
|
34
|
-
declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
|
|
35
|
-
"version"
|
|
36
|
-
]
|
|
37
|
-
assert __version__ == declared
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_both_names_that_take_a_subcommand_report_it() -> None:
|
|
41
|
-
"""Driven, because the console script is what a person runs."""
|
|
42
|
-
declared = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][
|
|
43
|
-
"version"
|
|
44
|
-
]
|
|
45
|
-
for entry in ("gw", "main"):
|
|
46
|
-
proc = subprocess.run(
|
|
47
|
-
[
|
|
48
|
-
sys.executable,
|
|
49
|
-
"-c",
|
|
50
|
-
f"from worktrees.cli import {entry}; raise SystemExit({entry}())",
|
|
51
|
-
"version",
|
|
52
|
-
],
|
|
53
|
-
capture_output=True,
|
|
54
|
-
text=True,
|
|
55
|
-
check=False,
|
|
56
|
-
)
|
|
57
|
-
assert proc.returncode == 0, proc.stderr
|
|
58
|
-
assert proc.stdout.strip() == declared, entry
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_the_build_hook_writes_and_ships_the_version(tmp_path) -> None:
|
|
62
|
-
"""Both halves, because either one alone fails in silence.
|
|
63
|
-
|
|
64
|
-
`.gitignore` keeps the generated file out of the repository, and hatchling
|
|
65
|
-
reads `.gitignore`, so a hook that only writes it builds a wheel without
|
|
66
|
-
it. Every install then falls back to the metadata lookup and nothing says
|
|
67
|
-
so.
|
|
68
|
-
"""
|
|
69
|
-
import hatch_build
|
|
70
|
-
|
|
71
|
-
assert hatch_build.GENERATED.as_posix() in (ROOT / ".gitignore").read_text()
|
|
72
|
-
|
|
73
|
-
(tmp_path / hatch_build.GENERATED.parent).mkdir(parents=True)
|
|
74
|
-
build_data: dict[str, list[str]] = {}
|
|
75
|
-
written = hatch_build.generate(tmp_path, "9.9.9", build_data)
|
|
76
|
-
|
|
77
|
-
namespace: dict[str, str] = {}
|
|
78
|
-
exec(written.read_text(), namespace)
|
|
79
|
-
assert namespace["__version__"] == "9.9.9"
|
|
80
|
-
assert f"/{hatch_build.GENERATED.as_posix()}" in build_data["artifacts"]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_a_checkout_falls_back_to_the_metadata_lookup() -> None:
|
|
84
|
-
"""No generated file is the normal state of a clone, and it still answers."""
|
|
85
|
-
source = (ROOT / "src" / "worktrees" / "__init__.py").read_text()
|
|
86
|
-
assert "from ._version import __version__" in source
|
|
87
|
-
assert "except ImportError" in source
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|