git-worktrees 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.agents/skills/release/SKILL.md +16 -6
  2. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.github/workflows/publish.yml +43 -40
  3. git_worktrees-0.3.0/.github/workflows/testpypi.yml +69 -0
  4. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.github/workflows/tests.yml +12 -1
  5. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.gitignore +3 -0
  6. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/AGENTS.md +41 -4
  7. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/CHANGELOG.md +82 -3
  8. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/PKG-INFO +31 -13
  9. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/README.md +29 -12
  10. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwr.fish +1 -0
  11. git_worktrees-0.3.0/docs/BENCHMARK.md +88 -0
  12. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/docs/DEVELOPMENT.md +86 -24
  13. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/functions/gwa.fish +6 -1
  14. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/functions/gwl.fish +6 -1
  15. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/functions/gwm.fish +6 -1
  16. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/functions/gwr.fish +6 -1
  17. git_worktrees-0.3.0/hatch_build.py +49 -0
  18. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/pyproject.toml +16 -3
  19. git_worktrees-0.3.0/scripts/README.md +76 -0
  20. git_worktrees-0.3.0/scripts/benchmark.py +363 -0
  21. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/scripts/verify-publish.bash +4 -3
  22. git_worktrees-0.3.0/src/worktrees/__init__.py +15 -0
  23. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/cli.py +185 -58
  24. git_worktrees-0.3.0/src/worktrees/errors.py +16 -0
  25. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/forge.py +17 -5
  26. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/git.py +138 -58
  27. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/new_branch.py +17 -7
  28. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/pick.py +11 -3
  29. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/prune.py +36 -0
  30. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/render.py +7 -3
  31. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/repo.py +59 -28
  32. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/rotate.py +20 -6
  33. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/verdicts.py +25 -6
  34. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/worktree.py +15 -7
  35. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/conftest.py +44 -0
  36. git_worktrees-0.3.0/tests/test_benchmark.py +136 -0
  37. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_cli.py +145 -17
  38. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_completions.py +6 -19
  39. git_worktrees-0.3.0/tests/test_fixture.py +69 -0
  40. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_forge.py +2 -9
  41. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_gates.py +51 -2
  42. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_new_branch.py +3 -15
  43. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_rotate.py +2 -14
  44. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_shims.py +46 -3
  45. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_version.py +30 -0
  46. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_worktree.py +18 -16
  47. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/uv.lock +36 -1
  48. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwr +1 -0
  49. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwa +14 -0
  50. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwl +14 -0
  51. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwm +14 -0
  52. git_worktrees-0.3.0/zsh/plugins/worktrees/functions/gwr +14 -0
  53. git_worktrees-0.2.1/src/worktrees/__init__.py +0 -8
  54. git_worktrees-0.2.1/zsh/plugins/worktrees/functions/gwa +0 -9
  55. git_worktrees-0.2.1/zsh/plugins/worktrees/functions/gwl +0 -9
  56. git_worktrees-0.2.1/zsh/plugins/worktrees/functions/gwm +0 -9
  57. git_worktrees-0.2.1/zsh/plugins/worktrees/functions/gwr +0 -9
  58. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.claude/settings.json +0 -0
  59. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.pre-commit-config.yaml +0 -0
  60. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/.python-version +0 -0
  61. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/LICENSE +0 -0
  62. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gw.fish +0 -0
  63. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwa.fish +0 -0
  64. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwh.fish +0 -0
  65. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwl.fish +0 -0
  66. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwm.fish +0 -0
  67. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwnb.fish +0 -0
  68. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwp.fish +0 -0
  69. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gwrot.fish +0 -0
  70. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/completions/gws.fish +0 -0
  71. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/conf.d/worktrees.fish +0 -0
  72. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/renovate.json +0 -0
  73. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/layout.py +0 -0
  74. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/src/worktrees/merged.py +0 -0
  75. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_render.py +0 -0
  76. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/tests/test_spec.py +0 -0
  77. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gw +0 -0
  78. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwa +0 -0
  79. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwh +0 -0
  80. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwl +0 -0
  81. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwm +0 -0
  82. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwnb +0 -0
  83. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwp +0 -0
  84. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gwrot +0 -0
  85. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/completions/_gws +0 -0
  86. {git_worktrees-0.2.1 → git_worktrees-0.3.0}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -43,7 +43,7 @@ rather than passing it.
43
43
  git switch --create release/v<version> --no-track origin/main
44
44
  ```
45
45
 
46
- Then run `/release-notes:draft <version>`, from the ReleaseTools plugin. It
46
+ Then run `/release-notes:prepare <version>`, from the ReleaseTools plugin. It
47
47
  rules on every commit since the previous tag, writes the body to
48
48
  `.git/RELEASE_EDITMSG`, and puts the entry in `CHANGELOG.md` under
49
49
  `## <version> - <date>`. It shows the draft and waits before its last step,
@@ -86,15 +86,18 @@ tagged and nothing published.
86
86
  ## 4. Merge, then wait for main
87
87
 
88
88
  ```bash
89
- gh pr merge --rebase --delete-branch
89
+ gh pr merge --squash --delete-branch
90
90
  git switch main && git pull --ff-only
91
91
  rt github::await_workflow "$(git rev-parse HEAD)" tests.yml
92
92
  ```
93
93
 
94
- `--rebase` keeps the commit message rather than replacing it with the pull
95
- request title.
94
+ `--squash`, because the `main` ruleset requires signed commits and linear
95
+ history: GitHub cannot sign the commits a rebase merge writes and refuses it
96
+ with `Base branch requires signed commits`, and a merge commit is off. A
97
+ squash is signed by GitHub's web-flow key, which is expected here and not
98
+ something to route around. The subject becomes `Release <version> (#NN)`.
96
99
 
97
- The wait matters. A rebase onto a main that moved is a tree neither the
100
+ The wait matters. A squash onto a main that moved is a tree neither the
98
101
  branch nor main has tested, and the tag must only ever land on a commit
99
102
  already proved green. `github::await_workflow` blocks until `tests.yml` finishes
100
103
  on that commit and exits non-zero if it failed.
@@ -113,7 +116,14 @@ gh run watch "$(gh run list --workflow publish.yml --limit 1 --json databaseId -
113
116
 
114
117
  Pushing the tag is the release. `publish.yml` re-checks the tag against
115
118
  `pyproject.toml`, that the commit is on `main`, and the tests, then builds
116
- once and uploads to TestPyPI before PyPI.
119
+ once, installs the wheel it built and uploads it to PyPI. There is no
120
+ TestPyPI upload here; `testpypi.yml` published this commit when the merge put
121
+ it on `main`.
122
+
123
+ `release` needs that upload alone, so the GitHub release appears while
124
+ `verify` is still installing from PyPI. A red `verify` under a green
125
+ `publish` means the index has not caught up, not that the version is
126
+ missing.
117
127
 
118
128
  Report the PyPI URL and the GitHub release URL when it finishes.
119
129
 
@@ -4,15 +4,18 @@ name: Publish
4
4
  # request against the repository, this workflow's FILENAME and the environment,
5
5
  # so a rename means re-registering the publisher on PyPI first.
6
6
  #
7
- # build -> publish-test -> publish -> release
7
+ # build -> 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
11
12
  # before it is a normal reviewed change.
12
13
  #
13
- # TestPyPI gates the real upload deliberately: a PyPI upload cannot be undone
14
- # or replaced, so if the rehearsal fails there is nothing to pin against and
15
- # the run stops there.
14
+ # There is no TestPyPI rehearsal. testpypi.yml publishes every commit on main
15
+ # there as <version>.post<epoch> and installs it back, so the tree this tag
16
+ # names has already proved it uploads to an index and installs from one. What
17
+ # is left to prove before the upload PyPI cannot take back is that the wheel
18
+ # runs, and build installs it and does that.
16
19
  #
17
20
  # No API token is stored anywhere. See docs/DEVELOPMENT.md, "Publishing a
18
21
  # version".
@@ -30,7 +33,7 @@ permissions:
30
33
 
31
34
  jobs:
32
35
  # Built once and handed to every job below, so what reaches PyPI is
33
- # byte-for-byte what TestPyPI accepted.
36
+ # byte-for-byte what was tested and installed here.
34
37
  build:
35
38
  name: Build
36
39
  runs-on: ubuntu-latest
@@ -91,6 +94,11 @@ jobs:
91
94
  - name: Install dependencies
92
95
  run: uv sync --all-extras --locked
93
96
 
97
+ # Neither shell is on the image, and the shim tests fail rather than
98
+ # skip under CI, so this job needs them exactly as tests.yml does.
99
+ - name: Install fish and zsh
100
+ run: sudo apt-get update && sudo apt-get install -y fish zsh
101
+
94
102
  # The tag is the last chance to catch a regression, and it costs 20s.
95
103
  - name: Run tests
96
104
  run: uv run --no-sync pytest tests -q
@@ -98,6 +106,17 @@ jobs:
98
106
  - name: Build
99
107
  run: uv build
100
108
 
109
+ # The wheel is not the tree: hatch_build.py writes _version.py into it,
110
+ # and one that lacks it falls back to the metadata lookup and says
111
+ # nothing. Installing it costs four seconds and happens before anything
112
+ # is uploaded anywhere.
113
+ - name: Install the wheel and run it
114
+ run: |
115
+ REPORTED="$(uv tool run --isolated --from dist/*.whl gw version)"
116
+ DECLARED="$(uv version --short)"
117
+ echo "the wheel reports $REPORTED, pyproject declares $DECLARED" >&2
118
+ [ "$REPORTED" = "$DECLARED" ]
119
+
101
120
  - name: Upload distributions
102
121
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
103
122
  with:
@@ -105,20 +124,18 @@ jobs:
105
124
  path: dist/
106
125
  if-no-files-found: error
107
126
 
108
- publish-test:
109
- name: Publish to TestPyPI
127
+ publish:
128
+ name: Publish to PyPI
110
129
  runs-on: ubuntu-latest
111
130
  needs: build
112
- # Binds this job to the trusted publisher registered for `testpypi`.
113
- environment: testpypi
131
+ # Binds this job to the trusted publisher registered for `pypi`. Add a
132
+ # required reviewer on this environment for a manual gate before anything
133
+ # reaches the real index.
134
+ environment: pypi
114
135
  permissions:
115
- # Lets the job mint the OIDC token PyPI exchanges for a short-lived,
116
- # scoped credential. Without it uv has nothing to present.
117
136
  id-token: write
118
137
  contents: read
119
138
  steps:
120
- # `--index testpypi` resolves that name from [[tool.uv.index]] in
121
- # pyproject.toml, which is not part of the built artifact.
122
139
  - name: Checkout code
123
140
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
124
141
 
@@ -135,22 +152,24 @@ jobs:
135
152
  path: dist/
136
153
 
137
154
  - name: Publish
138
- # `always` rather than the default `automatic`: fail loudly if OIDC is
139
- # unavailable instead of quietly looking for a token.
140
- run: uv publish --index testpypi --trusted-publishing always
155
+ run: uv publish --trusted-publishing always
141
156
 
142
- publish:
143
- name: Publish to PyPI
157
+ # Uploaded and installable are two claims with two timings, so they are two
158
+ # jobs. An index serves what it has accepted only after a delay of its own
159
+ # choosing: 0.2.1 took longer than the script waited, and a release that had
160
+ # published correctly went red and had to be re-run by hand. This job holds
161
+ # the second claim, `release` needs only the first, and a red `verify` says
162
+ # PyPI is slow rather than that the version is not out.
163
+ verify:
164
+ name: Verify it installs from PyPI
144
165
  runs-on: ubuntu-latest
145
- needs: publish-test
146
- # Binds this job to the trusted publisher registered for `pypi`. Add a
147
- # required reviewer on this environment for a manual gate before anything
148
- # reaches the real index.
149
- environment: pypi
166
+ needs: publish
150
167
  permissions:
151
- id-token: write
152
168
  contents: read
153
169
  steps:
170
+ # The script reads the name and version from pyproject.toml through
171
+ # `uv version`, and installs from the index rather than from the
172
+ # artifact, so there is nothing to download here.
154
173
  - name: Checkout code
155
174
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
156
175
 
@@ -160,22 +179,6 @@ jobs:
160
179
  python-version: "3.14"
161
180
  enable-cache: true
162
181
 
163
- - name: Download distributions
164
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
165
- with:
166
- name: dist
167
- path: dist/
168
-
169
- - name: Install the release tools
170
- uses: releasetools/cli@v0
171
-
172
- - name: Publish
173
- run: uv publish --trusted-publishing always
174
-
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.
179
182
  - name: Verify it installs from PyPI
180
183
  run: scripts/verify-publish.bash --prod
181
184
 
@@ -0,0 +1,69 @@
1
+ name: TestPyPI
2
+
3
+ # Renaming this file breaks the upload. TestPyPI's Trusted Publishing matches a
4
+ # request against the repository, this workflow's FILENAME and the environment,
5
+ # so a rename means re-registering the publisher on TestPyPI first.
6
+ #
7
+ # Every commit on main is published here as <version>.post<epoch>. A build made
8
+ # after <version> shipped is a post release of it, which is what PEP 440's post
9
+ # segment says, and it sorts above <version> and below the next patch, so the
10
+ # newest snapshot is what TestPyPI serves.
11
+ #
12
+ # This is what proves the publishing path. The tree a release tag names has
13
+ # already been uploaded and installed back from an index by the time the tag
14
+ # exists, which is why publish.yml rehearses nothing of its own.
15
+ #
16
+ # The suite is not run again here. tests.yml runs it on this same commit, and a
17
+ # snapshot a test would have caught costs one number on a test index.
18
+
19
+ on:
20
+ push:
21
+ branches:
22
+ - main
23
+ workflow_dispatch:
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ jobs:
29
+ snapshot:
30
+ name: Publish a snapshot
31
+ runs-on: ubuntu-latest
32
+ # Binds this job to the trusted publisher registered for `testpypi`.
33
+ environment: testpypi
34
+ permissions:
35
+ # Lets the job mint the OIDC token TestPyPI exchanges for a short-lived,
36
+ # scoped credential. Without it uv has nothing to present.
37
+ id-token: write
38
+ contents: read
39
+ steps:
40
+ # `--index testpypi` resolves that name from [[tool.uv.index]] in
41
+ # pyproject.toml, and the script reads the version from the same file.
42
+ - name: Checkout code
43
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
44
+
45
+ - name: Set up uv
46
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
47
+ with:
48
+ python-version: "3.14"
49
+ enable-cache: true
50
+
51
+ # Epoch seconds rather than the run number: a re-run repeats the number
52
+ # and TestPyPI refuses the duplicate, while the clock has moved on.
53
+ # `--frozen` writes pyproject.toml and leaves uv.lock alone, which is
54
+ # correct here because nothing in this job resolves dependencies.
55
+ - name: Number the snapshot
56
+ run: uv version --frozen "$(uv version --short).post$(date +%s)"
57
+
58
+ - name: Build
59
+ run: uv build
60
+
61
+ - name: Publish
62
+ # `always` rather than the default `automatic`: fail loudly if OIDC is
63
+ # unavailable instead of quietly looking for a token.
64
+ run: uv publish --index testpypi --trusted-publishing always
65
+
66
+ # Uploaded is not installable, and this is the claim the release path
67
+ # inherits from these runs.
68
+ - name: Verify it installs from TestPyPI
69
+ run: scripts/verify-publish.bash
@@ -56,6 +56,11 @@ jobs:
56
56
  - name: Checkout code
57
57
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
58
58
 
59
+ # python-version makes setup-uv run `uv python install`, so each entry
60
+ # gets a uv-managed cpython-X.Y.Z rather than whatever the runner image
61
+ # ships. Packagers differ enough to matter: one that links its C
62
+ # extensions into the executable and one that dlopens 76 of them do not
63
+ # start in the same time. scripts/README.md has the measurement.
59
64
  - name: Set up uv and Python ${{ matrix.python-version }}
60
65
  uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
61
66
  with:
@@ -67,11 +72,17 @@ jobs:
67
72
  # resolving something the lock file never described.
68
73
  run: uv sync --all-extras --locked
69
74
 
75
+ # Neither shell is on the image. Without them the nine assertions that
76
+ # drive a real fish or zsh call pytest.skip, which is invisible in
77
+ # `pytest -q`, so the run goes green without either shim ever running.
78
+ - name: Install fish and zsh
79
+ run: sudo apt-get update && sudo apt-get install -y fish zsh
80
+
70
81
  # The suite drives real git: it builds repositories, adds worktrees and
71
82
  # removes them. Recording the version makes a failure that is git's
72
83
  # rather than ours readable from the log alone.
73
84
  - name: Record the git version
74
- run: git --version
85
+ run: git --version && fish --version && zsh --version
75
86
 
76
87
  - name: Run tests
77
88
  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
@@ -6,10 +6,20 @@ through `subprocess` so env, cwd, both streams and the exit code stay per-test.
6
6
 
7
7
  ```console
8
8
  uv sync --all-extras
9
+ uv run --no-sync pre-commit install
9
10
  uv run --no-sync pytest tests
10
11
  uv run --no-sync pre-commit run --all-files
11
12
  ```
12
13
 
14
+ `pre-commit run --all-files` reads `git ls-files`, so a file that is new and
15
+ unstaged is invisible to it. Stage before running the hooks, or install them
16
+ and let the commit do it.
17
+
18
+ Read [scripts/README.md](scripts/README.md) before measuring anything. It says
19
+ what is worth timing and what is not, and why every interpreter the benchmark
20
+ touches comes from uv: a run that compares packagers rather than versions
21
+ produces a number that looks like a CPython regression and is not one.
22
+
13
23
  ## Binaries and shell functions are not interchangeable
14
24
 
15
25
  A shell function of a given name beats a binary of that name in bash, zsh and
@@ -19,7 +29,12 @@ prompt and from a script unless the function is a pure adapter.
19
29
 
20
30
  The rule: **a function exists only for a command that must change the caller's
21
31
  directory, it carries the same name as the binary, and its body does nothing but
22
- call `command <name>` and `cd` to what that prints.**
32
+ call `command <name>` and `cd` to what that prints, when that is a directory.**
33
+
34
+ The last clause is not a hedge. `--json` and `--list` print what was asked for
35
+ on the same stream a destination arrives on, and a shim that cannot tell them
36
+ apart tries to `cd` into a JSON object. `fish -c 'gwl --json'` reaches the
37
+ function too, so this is the agent path as much as the human one.
23
38
 
24
39
  | | changes directory | ships as |
25
40
  | --- | --- | --- |
@@ -129,7 +144,9 @@ tightly they cluster. About twenty lines, `difflib` not required.
129
144
 
130
145
  Two rules, both the shape `gwp`'s prompt already has:
131
146
 
132
- - One match takes it outright, with no prompt at all.
147
+ - A query that narrows to one takes it outright, with no prompt at all. No
148
+ query asks however many there are, because being moved without being asked
149
+ is not what `gwl` with no argument means.
133
150
  - No terminal, no prompt. Refuse at exit 3 and name the flag that answers
134
151
  without one, rather than blocking on something nothing can drive.
135
152
 
@@ -238,10 +255,30 @@ tested on what it returns rather than on its exit code. `upstream_of` takes
238
255
  `ok=(0, 1, 128)`, so a typo in one of its flags exits 128, reads as "no
239
256
  upstream", and turns every branch into `unknown` with nothing failing.
240
257
 
258
+ The two calls that reach the network, `fetch` and `remote set-head --auto`,
259
+ take `ok=(0, 128)` for the same reason in reverse: a remote that cannot be
260
+ reached is an answer rather than a failure. The caller reads the result, says
261
+ so on stderr, and carries on with the refs already here, which is where
262
+ `--no-fetch` sends it on request. It also stops asking: a failed fetch means
263
+ the head-branch ladder is told it is offline, rather than spending a second
264
+ round trip proving it.
265
+
266
+ One `git status --porcelain --ignored=traditional` answers both questions a
267
+ worktree is asked. It is a strict superset of plain `--porcelain`, so `dirty`
268
+ is "a line that does not start with `!! `" and `ignored` is the rest. Asking
269
+ both ways was 18 of an assessment's 35 calls over nine worktrees.
270
+
241
271
  `guard()` runs on the resolved argv inside the wrapper, not at declaration, so
242
272
  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.
273
+ or `switch`, `clean -f`, a bare `push --force`, `worktree remove --force`,
274
+ `branch -D` and an `update-ref` delete that names no full sha are refused
275
+ absolutely. There is no flag, and `--yes` least of all.
276
+
277
+ Those seven are `RULES`, a tuple, each row carrying the name `--explain`
278
+ prints, the clause the refusal ends with, and the test. The footer `--explain`
279
+ prints is built from it. A rule written in one place and described in another
280
+ is a rule that goes quiet: the hand-written footer fell a rule behind the
281
+ moment `update-ref` was added, and nothing failed.
245
282
 
246
283
  `verdicts.py` declares no mutating command. A read-only command may not call
247
284
  one: a test reads the verbose log and asserts it.
@@ -1,9 +1,14 @@
1
1
  # Changelog
2
2
 
3
3
  Newest first. Each entry says what changed for somebody running these
4
- commands, and the choices behind it. Dates are ISO 8601, versions follow
5
- [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and the shape
6
- follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+ commands, and the choices behind it. Dates are ISO 8601, released versions
5
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and the
6
+ shape follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
+
8
+ Every version below was tagged and published. The snapshots `main` sends to
9
+ TestPyPI are neither, and they are numbered `<version>.post<epoch>`, which
10
+ PEP 440 orders after the release it follows and SemVer does not describe at
11
+ all. `docs/DEVELOPMENT.md` says how and why.
7
12
 
8
13
  An entry earns its place by being observable. If running a command produces
9
14
  no different result, no different output and no different exit code, it is
@@ -12,6 +17,80 @@ not in here, whatever it cost to build.
12
17
  A release closes with a `### Choices` section when a decision in it is worth
13
18
  the reader's time: what was chosen, and what the alternative failed to do.
14
19
 
20
+ ## 0.3.0 - 2026-09-20
21
+
22
+ `gwl` with no query asks which worktree rather than moving you to one.
23
+ Whenever exactly one was left it took it outright, so a repository holding
24
+ the main checkout and one worktree moved you without a word, and running it
25
+ twice walked you back and forth. A query that narrows to one has already said
26
+ which and still takes it outright. Without a terminal it now refuses at exit
27
+ 3 and names `--list` and `--json`, where it used to print a path, so
28
+ `cd $(gwl)` in a script stops working. `gwl <query>` is unchanged.
29
+
30
+ `gwl --list` no longer tries to `cd` into its own listing, and `--json` no
31
+ longer tries to `cd` into the JSON. The shell function reads what the binary
32
+ printed and moves you there, and those two modes print on the same stream a
33
+ destination arrives on, so every one of them ended in a failed `cd`. `gwa
34
+ --json` was the worst: the worktree was created and then the move failed, so
35
+ the run looked broken and was not. It reached `gwa`, `gwl`, `gwm` and `gwr`
36
+ in both fish and zsh, and `fish -c 'gwl --json'` too, because a fish function
37
+ wins there. **That fix lives in the shell functions, so `uv tool upgrade`
38
+ alone does not deliver it; update the fish or zsh plugin as well.**
39
+
40
+ An invocation costs about 18 ms less on Python 3.11 and about 12 ms less on
41
+ 3.14. `gw version` was 48.4 ms and is 30.5 ms on 3.11.13, and was 55.6 ms and
42
+ is 43.1 ms on 3.14.7, measured from installed wheels on one machine. Four
43
+ changes account for it: the command you typed imports only what it needs
44
+ rather than every command, records are plain classes rather than dataclasses,
45
+ the parser is built for the subcommand you asked for rather than all eleven,
46
+ and `json` is imported only where it is printed. `difflib`, which named the
47
+ closest command when you mistyped one, is no longer imported to start.
48
+
49
+ ### Choices
50
+
51
+ `gwl` asks whenever no query was given, even when the repository holds one
52
+ other worktree. Taking it outright reads as a toggle: the command moves you,
53
+ and moves you back, and never says where. Prompting for a single option looks
54
+ redundant until it is the difference between being shown your options and
55
+ being sent somewhere. A query is the way to skip it, and it always was.
56
+
57
+ `gwl` without a terminal exits 3, joining `gwp`, which 0.2.3 moved there for
58
+ the same reason. A script can then tell a refusal from a typo: 2 is
59
+ argparse's code for a usage error and 3 is nobody else's.
60
+
61
+ ## 0.2.3 - 2026-09-11
62
+
63
+ An unreachable remote is an answer rather than a failure. Every command that
64
+ touches the network used to exit 1 with git's own message, so the warnings
65
+ written for the offline case never reached anybody. A failed fetch now says
66
+ the refs are the ones already here, and the assessment carries on.
67
+
68
+ Refusing to prompt exits 3 everywhere. `gwp` used to exit 2 when stdin was
69
+ not a terminal, which is argparse's code for a usage error, so a script could
70
+ not tell a refusal from a typo.
71
+
72
+ Removing a worktree names the stash entries made on its branch. A stash
73
+ outlives the branch while its subject goes on naming it, which is how it
74
+ becomes unfindable.
75
+
76
+ `gwr` asks the forge once, about the branch it is removing, instead of about
77
+ every worktree: three round trips became one in a three-worktree repository.
78
+ `--no-forge` turns it off there, as it already did for `gws` and `gwp`.
79
+
80
+ `--explain` lists all seven refusals the guard enforces. The footer was
81
+ written by hand and had been a rule behind since `update-ref` was added.
82
+
83
+ An invocation costs about 15 ms less, and an assessment makes half as many
84
+ `git status` calls.
85
+
86
+ ### Choices
87
+
88
+ The version is written into the wheel at build time rather than read back
89
+ through `importlib.metadata`, which cost 10 ms of a 67 ms invocation for a
90
+ string two commands print. Deriving it from the tag with `hatch-vcs` was the
91
+ alternative, and it inverts which of the tag and `pyproject.toml` has to
92
+ agree with the other.
93
+
15
94
  ## 0.2.1 - 2026-09-11
16
95
 
17
96
  `--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.3.0
4
4
  Summary: Git worktree commands that refuse to lose work.
5
5
  Project-URL: Bug reports, https://github.com/MihaiBojin/worktrees/issues/new
6
6
  Project-URL: Source, https://github.com/MihaiBojin/worktrees
@@ -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
@@ -147,8 +148,10 @@ autoload -Uz compinit && compinit
147
148
 
148
149
  ### What the shell code does
149
150
 
150
- Each function runs `command <name>`, reads the one path the binary printed and
151
- `cd`s there, and carries nothing else. `gws`, `gwp`, `gwnb`, `gwrot` and `gwh`
151
+ Each function runs `command <name>` and `cd`s to what it printed, provided
152
+ that is a directory. `--json` and `--list` print what was asked for on the
153
+ same stream a destination arrives on, so the shim asks whether what it has is
154
+ somewhere to go and stays put when it is not. `gws`, `gwp`, `gwnb`, `gwrot` and `gwh`
152
155
  change no directory, so they ship as console scripts alone and answer the same
153
156
  from a prompt and from a script.
154
157
 
@@ -222,6 +225,12 @@ function returns that set for both. What goes and what puts it back is printed
222
225
  before anything does, and neither `--quiet` nor `--yes` silences it. The sha in
223
226
  the restore line is full length, because that line is meant to be pasted.
224
227
 
228
+ A stash made on a branch that is about to go is named too. `refs/stash` is
229
+ per-repository and pins its own commits, so the entry survives the branch and
230
+ `git stash branch <new> stash@{0}` still recovers it. What does not survive is
231
+ the branch name in its subject, so the line says which entry is about to start
232
+ pointing at nothing.
233
+
225
234
  The checkout goes through plain `git worktree remove`, so every refusal git
226
235
  makes still applies. The branch goes through `git update-ref -d`, naming the
227
236
  sha the verdict was formed against: a branch somebody committed to in between
@@ -240,7 +249,8 @@ squash-merged /home/you/git/.worktrees/squash-merged/repo
240
249
  not a terminal, so nothing can answer for the 1 above; pass --yes to remove them
241
250
  ```
242
251
 
243
- That exits 2 and touches nothing. `gwp --yes --json` is what an agent runs.
252
+ That exits 3 and touches nothing, the code every refusal uses. `gwp --yes --json`
253
+ is what an agent runs.
244
254
 
245
255
  With nothing to remove it prints the table `gws` prints, rather than the name
246
256
  of the command that would have printed it. The reason each worktree stayed is
@@ -275,9 +285,10 @@ $ gwm parser-v2 # renames the branch and moves the checkout
275
285
  $ gwr # removes the one you are standing in
276
286
  ```
277
287
 
278
- Each prints one destination on stdout and nothing else, which is what the
279
- shell function reads. `gwr` prints a path **only** when you were standing in
280
- what it removed; empty output means stay put.
288
+ Each prints one destination on stdout, which is what the shell function
289
+ reads, and prints data there instead under `--json` and `--list`. `gwr` prints
290
+ a path **only** when you were standing in what it removed; empty output, and
291
+ output that is not a directory, both mean stay put.
281
292
 
282
293
  `gwm` is the one that is not a convenience. Renaming the directory you are
283
294
  standing in leaves the shell with a stale `$PWD` and every later command
@@ -302,16 +313,17 @@ $ gwl --list
302
313
  ```
303
314
 
304
315
  It never offers that one. Picking it is the one answer that cannot take you
305
- anywhere, and a picker whose single candidate is where you already are prints
306
- nothing at all, which reads as a broken command. So two worktrees and no query
307
- is not a question, and asking for the one you are in says so and stays put:
316
+ anywhere, and asking for the one you are in says so and stays put:
308
317
 
309
318
  ```console
310
319
  $ gwl dirty # standing in dirty-work
311
320
  already in dirty-work
312
321
  ```
313
322
 
314
- With more than one worth offering it asks, numbered:
323
+ A query that narrows to one takes it outright. No query does not, even when
324
+ the repository holds exactly one other worktree: `gwl` with no argument means
325
+ show me the options, and being moved without being asked is not that. So it
326
+ asks, numbered, however many there are:
315
327
 
316
328
  ```console
317
329
  $ gwl # standing in the main checkout
@@ -422,13 +434,17 @@ print one thing each and have nothing to be quiet about, takes `--json`, `-q`,
422
434
  | --- | --- |
423
435
  | `gws` | `--branch NAME`, `--no-fetch`, `--delete-ignored`, `--no-forge` |
424
436
  | `gwp` | those four, and `-y` |
425
- | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `-y` |
437
+ | `gwr` | `-f`, `--delete-ignored`, `--no-fetch`, `--no-forge`, `-y` |
426
438
  | `gwa`, `gwnb`, `gwrot` | `--no-fetch` |
427
439
  | `gwl` | `-l` |
428
440
  | `gwm`, `gwh` | none |
429
441
 
430
442
  `gw <command> --help` prints one command's own list.
431
443
 
444
+ A remote that cannot be reached is not fatal anywhere. The fetch fails, the
445
+ command says so on stderr, and it answers from the refs already here, which is
446
+ where `--no-fetch` sends it on request.
447
+
432
448
  Data goes to stdout and diagnostics to stderr, the prompt included, so `--json`
433
449
  is parseable in every mode. Colour is decided per stream and only for a
434
450
  terminal, so a redirect, a pipe, a non-empty `NO_COLOR` or `TERM=dumb` give the
@@ -460,12 +476,14 @@ and whatever a caller asks for:
460
476
  ```
461
477
  reset --hard a forced checkout or switch clean -f
462
478
  push --force worktree remove --force branch -D
479
+ an update-ref delete that names no full sha
463
480
  ```
464
481
 
465
482
  The check runs on the argument list as it is about to be handed to git, so no
466
483
  code path can assemble its way past one. `gws --explain` prints every git
467
484
  command the program can issue, marking the ones that take the repository's
468
- shared refs and therefore run serially.
485
+ shared refs and therefore run serially, and ends with that list of seven,
486
+ generated from the rules rather than written out beside them.
469
487
 
470
488
  `--force-with-lease` is not `--force` and is allowed, and so is
471
489
  `update-ref -d <ref> <sha>`, which is how a branch is deleted here: against the