git-worktrees 0.4.1__tar.gz → 0.4.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.4.3/.claude/settings.json +6 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.github/workflows/publish.yml +2 -4
- git_worktrees-0.4.3/.releasetools.yaml +23 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/AGENTS.md +4 -4
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/CHANGELOG.md +12 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/PKG-INFO +7 -5
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/README.md +6 -4
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/docs/DEVELOPMENT.md +28 -14
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/pyproject.toml +1 -1
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/forge.py +3 -3
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/repo.py +5 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/verdicts.py +16 -6
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_forge.py +98 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/uv.lock +1 -1
- git_worktrees-0.4.1/.agents/skills/release/SKILL.md +0 -138
- git_worktrees-0.4.1/.claude/settings.json +0 -5
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.github/workflows/testpypi.yml +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.github/workflows/tests.yml +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.gitignore +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.pre-commit-config.yaml +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.python-version +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/LICENSE +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gw.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwa.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwh.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwl.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwm.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwnb.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwp.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwr.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwrot.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gws.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/conf.d/worktrees.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/docs/BENCHMARK.md +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/docs/REFERENCE.md +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwa.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwl.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwm.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwr.fish +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/hatch_build.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/renovate.json +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/scripts/README.md +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/scripts/benchmark.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/scripts/verify-publish.bash +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/__init__.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/cli.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/errors.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/git.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/layout.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/merged.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/new_branch.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/pick.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/prune.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/render.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/rotate.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/worktree.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/conftest.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_benchmark.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_cli.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_completions.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_fixture.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_gates.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_new_branch.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_render.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_rotate.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_shims.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_spec.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_version.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_worktree.py +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gw +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwa +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwh +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwl +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwm +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwnb +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwp +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwr +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwrot +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gws +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwa +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwl +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwm +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwr +0 -0
- {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
|
@@ -144,8 +144,7 @@ jobs:
|
|
|
144
144
|
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
|
145
145
|
with:
|
|
146
146
|
python-version: "3.14"
|
|
147
|
-
enable-cache:
|
|
148
|
-
cache-suffix: ${{ github.workflow }}-${{ github.job }}
|
|
147
|
+
enable-cache: false
|
|
149
148
|
|
|
150
149
|
- name: Download distributions
|
|
151
150
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
|
@@ -179,8 +178,7 @@ jobs:
|
|
|
179
178
|
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
|
180
179
|
with:
|
|
181
180
|
python-version: "3.14"
|
|
182
|
-
enable-cache:
|
|
183
|
-
cache-suffix: ${{ github.workflow }}-${{ github.job }}
|
|
181
|
+
enable-cache: false
|
|
184
182
|
|
|
185
183
|
- name: Verify it installs from PyPI
|
|
186
184
|
run: scripts/verify-publish.bash --prod
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# How this repository releases, read by every releasetools tool.
|
|
2
|
+
#
|
|
3
|
+
# Conventions: https://github.com/releasetools/conventions
|
|
4
|
+
#
|
|
5
|
+
# One project: the distribution is `git-worktrees`, which is not the name of
|
|
6
|
+
# this repository and not the package anything imports. PyPI already holds a
|
|
7
|
+
# `worktrees` belonging to somebody else, so the registry URL names the
|
|
8
|
+
# distribution rather than either of the others.
|
|
9
|
+
projects:
|
|
10
|
+
- path: ./
|
|
11
|
+
manifest: pyproject.toml
|
|
12
|
+
changelog: CHANGELOG.md
|
|
13
|
+
bump: uv version {version}
|
|
14
|
+
|
|
15
|
+
release:
|
|
16
|
+
branch: main
|
|
17
|
+
# GitHub replays the author's commits unsigned on a rebase merge, and the
|
|
18
|
+
# `main` ruleset requires signed commits and linear history, so a squash is
|
|
19
|
+
# the only one it takes. It is signed by GitHub's web-flow key.
|
|
20
|
+
merge: squash
|
|
21
|
+
checks: tests.yml
|
|
22
|
+
publish: publish.yml
|
|
23
|
+
registry: https://pypi.org/pypi/git-worktrees/{version}/json
|
|
@@ -300,10 +300,10 @@ regions. `git merge-tree` reports a conflict, and a diff cannot tell that
|
|
|
300
300
|
from real work left over. There is no content probe that answers this.
|
|
301
301
|
|
|
302
302
|
So the forge answers it, asked last because it costs a round trip and only
|
|
303
|
-
where git already failed. A merged request
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
`--no-forge` keeps a run offline.
|
|
303
|
+
where git already failed. A merged request with a configured upstream that
|
|
304
|
+
Git reports as `[gone]` permits pruning, subject to the worktree protections.
|
|
305
|
+
When the upstream exists, `unpushed_count` checks for local commits it lacks.
|
|
306
|
+
No upstream configured stays `unknown`. `--no-forge` keeps a run offline.
|
|
307
307
|
|
|
308
308
|
## Three verdicts, and the third is not a softer second
|
|
309
309
|
|
|
@@ -17,6 +17,18 @@ 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.4.3 - 2026-09-22
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
`gwp` can prune a branch whose pull request is merged and whose configured
|
|
25
|
+
upstream is gone. Branches with no upstream configured remain unknown.
|
|
26
|
+
Forge-confirmed branches with ignored files require `--delete-ignored`.
|
|
27
|
+
|
|
28
|
+
## 0.4.2 - 2026-09-22
|
|
29
|
+
|
|
30
|
+
CLI behavior is unchanged from 0.4.1.
|
|
31
|
+
|
|
20
32
|
## 0.4.1 - 2026-09-21
|
|
21
33
|
|
|
22
34
|
CLI behavior is unchanged from 0.4.0.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: git-worktrees
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -223,10 +223,12 @@ Where git cannot tell, the forge is asked. A branch merged as part of a
|
|
|
223
223
|
stack is that case: its changes reach the head branch across several
|
|
224
224
|
squashes, so the intermediate state it holds differs from the final one in
|
|
225
225
|
the same regions, and a diff cannot separate a stale branch from one with
|
|
226
|
-
work left. A merged pull request
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
work left. A merged pull request with a configured upstream that is gone
|
|
227
|
+
makes the branch eligible for pruning. When the upstream still exists,
|
|
228
|
+
unpushed commits keep the branch. A branch with no upstream configured
|
|
229
|
+
stays `unknown`. Worktree protections still apply, including the check for
|
|
230
|
+
ignored files. `--no-forge` decides from git alone, and so does a run whose
|
|
231
|
+
fetch just failed.
|
|
230
232
|
|
|
231
233
|
The question goes to the repository the command is standing in and nowhere
|
|
232
234
|
else. `GH_REPO` and `GITLAB_REPO` beat that directory, so both are dropped
|
|
@@ -197,10 +197,12 @@ Where git cannot tell, the forge is asked. A branch merged as part of a
|
|
|
197
197
|
stack is that case: its changes reach the head branch across several
|
|
198
198
|
squashes, so the intermediate state it holds differs from the final one in
|
|
199
199
|
the same regions, and a diff cannot separate a stale branch from one with
|
|
200
|
-
work left. A merged pull request
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
work left. A merged pull request with a configured upstream that is gone
|
|
201
|
+
makes the branch eligible for pruning. When the upstream still exists,
|
|
202
|
+
unpushed commits keep the branch. A branch with no upstream configured
|
|
203
|
+
stays `unknown`. Worktree protections still apply, including the check for
|
|
204
|
+
ignored files. `--no-forge` decides from git alone, and so does a run whose
|
|
205
|
+
fetch just failed.
|
|
204
206
|
|
|
205
207
|
The question goes to the repository the command is standing in and nowhere
|
|
206
208
|
else. `GH_REPO` and `GITLAB_REPO` beat that directory, so both are dropped
|
|
@@ -152,36 +152,50 @@ network. `GIT_CONFIG_GLOBAL` carries that: without it git still reads
|
|
|
152
152
|
## Publishing a version
|
|
153
153
|
|
|
154
154
|
```console
|
|
155
|
-
/release 0.2.0
|
|
155
|
+
/release:cut 0.2.0
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
`
|
|
160
|
-
|
|
161
|
-
on
|
|
162
|
-
|
|
158
|
+
That does the whole of it: cut `release/v0.2.0` from `origin/main`,
|
|
159
|
+
`uv version 0.2.0`, open the pull request, wait for its checks, squash-merge,
|
|
160
|
+
wait for main's own run on the merged commit, and only then tag. The tag lands
|
|
161
|
+
on a commit already proved green, so it never has to move.
|
|
162
|
+
|
|
163
|
+
What each step needs is in `.releasetools.yaml`: the branch, the squash, the
|
|
164
|
+
workflow that has to be green, the workflow the tag starts, the URL that must
|
|
165
|
+
not already carry the version, and the command that sets it. The keys are the
|
|
166
|
+
[releasetools conventions](https://github.com/releasetools/conventions/blob/main/FORMAT.md),
|
|
167
|
+
so the guards and the release read the same file.
|
|
168
|
+
|
|
169
|
+
The squash is not a preference. The `main` ruleset requires signed commits and
|
|
170
|
+
linear history: GitHub replays the author's commits unsigned on a rebase merge
|
|
171
|
+
and refuses with `Base branch requires signed commits`, and a merge commit is
|
|
172
|
+
not linear. A squash is one commit, signed by GitHub's web-flow key, and its
|
|
173
|
+
subject becomes `Release 0.2.0 (#NN)`.
|
|
163
174
|
|
|
164
175
|
`rt release::prechecks` runs first and refuses a version that is not `x.y.z`,
|
|
165
176
|
a dirty tree, a tag that already exists on the remote, a version that is not
|
|
166
177
|
after the newest release tag, a commit that is not on main, and a version PyPI
|
|
167
178
|
already carries. That last one matters most: PyPI rejects a duplicate at the
|
|
168
|
-
very end of a publish run, after everything else has already happened.
|
|
179
|
+
very end of a publish run, after everything else has already happened. The
|
|
180
|
+
distribution is `git-worktrees`; PyPI's `worktrees` belongs to somebody else.
|
|
169
181
|
|
|
170
182
|
`/release-notes:prepare` writes the `CHANGELOG.md` entry on the release branch,
|
|
171
|
-
so the notes are reviewed in the same pull request as the version bump.
|
|
172
|
-
|
|
183
|
+
so the notes are reviewed in the same pull request as the version bump. Both
|
|
184
|
+
commands come from the release-tools marketplace:
|
|
173
185
|
|
|
174
186
|
```console
|
|
175
187
|
claude plugin marketplace add releasetools/agent-plugins --scope project
|
|
188
|
+
claude plugin install release@release-tools --scope project
|
|
176
189
|
claude plugin install release-notes@release-tools --scope project
|
|
177
190
|
```
|
|
178
191
|
|
|
179
|
-
`--scope project` declares
|
|
180
|
-
configuration, so the next person to release is offered the same
|
|
192
|
+
`--scope project` declares them in this repository rather than in your own
|
|
193
|
+
configuration, so the next person to release is offered the same plugins.
|
|
194
|
+
`rt` has to be v0.4.0 or newer, which is where `version::bump` arrived.
|
|
181
195
|
|
|
182
|
-
`publish.yml` reads that section back out for the GitHub release,
|
|
183
|
-
refuses a tag whose version has no section, before anything is
|
|
184
|
-
Nothing is generated from pull request titles.
|
|
196
|
+
`publish.yml` reads that changelog section back out for the GitHub release,
|
|
197
|
+
and `build` refuses a tag whose version has no section, before anything is
|
|
198
|
+
published. Nothing is generated from pull request titles.
|
|
185
199
|
|
|
186
200
|
By hand it is the same four commands:
|
|
187
201
|
|
|
@@ -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.4.
|
|
12
|
+
version = "0.4.3"
|
|
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.
|
|
@@ -6,9 +6,9 @@ state it holds differs from the final one in the same regions, so a stale
|
|
|
6
6
|
branch whose work is upstream and a branch with real work left look exactly
|
|
7
7
|
alike to a diff.
|
|
8
8
|
|
|
9
|
-
The forge
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
The forge is asked after the content checks. An existing upstream is checked
|
|
10
|
+
for unpushed commits. A merged request with a missing configured upstream
|
|
11
|
+
permits pruning, subject to the worktree protections.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
@@ -59,6 +59,11 @@ def upstream_of(branch: str) -> None:
|
|
|
59
59
|
"""The upstream a branch tracks. Failure means unknown, not none."""
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
@git("for-each-ref --format=%(upstream:track) refs/heads/$branch")
|
|
63
|
+
def upstream_tracking(branch: str) -> None:
|
|
64
|
+
"""Tracking status, including [gone] for a missing configured upstream."""
|
|
65
|
+
|
|
66
|
+
|
|
62
67
|
@git("rev-list --count $a..$b", ok=(0, 128))
|
|
63
68
|
def count_between(a: str, b: str) -> None:
|
|
64
69
|
"""How many commits b has that a does not."""
|
|
@@ -185,11 +185,9 @@ def _forge_reason(
|
|
|
185
185
|
Returns (reason, verdict), or ("", "") when the forge said nothing and
|
|
186
186
|
the content probes keep the answer.
|
|
187
187
|
|
|
188
|
-
A merged request
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
upstream" is the normal state for exactly the ones this would otherwise
|
|
192
|
-
reap.
|
|
188
|
+
A merged request with a missing configured upstream permits pruning.
|
|
189
|
+
An existing upstream is checked for unpushed commits. A branch with no
|
|
190
|
+
tracking configuration stays unknown.
|
|
193
191
|
"""
|
|
194
192
|
request = forge.request_for(branch, repo=repo)
|
|
195
193
|
if request is None or request.state not in ("MERGED", "CLOSED"):
|
|
@@ -198,6 +196,18 @@ def _forge_reason(
|
|
|
198
196
|
lower = request.state.lower()
|
|
199
197
|
unpushed = R.unpushed_count(branch, repo=repo)
|
|
200
198
|
if unpushed is None:
|
|
199
|
+
if R.upstream_tracking(branch, repo=repo).out.strip() == "[gone]":
|
|
200
|
+
if request.state == "MERGED":
|
|
201
|
+
return (
|
|
202
|
+
f"its {request.noun} #{request.number} is merged "
|
|
203
|
+
"and its upstream is gone",
|
|
204
|
+
"",
|
|
205
|
+
)
|
|
206
|
+
return (
|
|
207
|
+
f"its {request.noun} is {lower}, but its upstream is gone; "
|
|
208
|
+
"cannot check for unpushed commits",
|
|
209
|
+
UNKNOWN,
|
|
210
|
+
)
|
|
201
211
|
return (
|
|
202
212
|
f"its {request.noun} is {lower}, but the branch has no upstream "
|
|
203
213
|
"to have been pushed to",
|
|
@@ -209,4 +219,4 @@ def _forge_reason(
|
|
|
209
219
|
"are not in it",
|
|
210
220
|
KEEP,
|
|
211
221
|
)
|
|
212
|
-
return f"its {request.noun} #{request.number} is {lower}",
|
|
222
|
+
return f"its {request.noun} #{request.number} is {lower}", ""
|
|
@@ -94,6 +94,22 @@ def test_a_merged_request_cannot_speak_for_unpushed_commits(world, with_forge) -
|
|
|
94
94
|
assert "1 commit(s) here are not in it" in row.why
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
@pytest.mark.parametrize("delete_ignored", [False, True])
|
|
98
|
+
def test_a_merged_request_with_an_existing_upstream_checks_ignored_files(
|
|
99
|
+
world, with_forge, delete_ignored
|
|
100
|
+
):
|
|
101
|
+
with_forge([{"number": 12, "state": "MERGED"}])
|
|
102
|
+
wt = world.worktree("stacked")
|
|
103
|
+
world.commit(".gitignore", "cache\n", at=wt)
|
|
104
|
+
_push(world, "stacked", wt)
|
|
105
|
+
(wt / "cache").write_text("keep\n")
|
|
106
|
+
rows = verdicts.assess(
|
|
107
|
+
R.worktrees(), "", "refs/heads/main", "main", delete_ignored, ask_forge=True
|
|
108
|
+
)
|
|
109
|
+
row = next(v for v in rows if v.branch == "stacked")
|
|
110
|
+
assert row.verdict == (verdicts.REMOVE if delete_ignored else verdicts.KEEP)
|
|
111
|
+
|
|
112
|
+
|
|
97
113
|
def test_no_upstream_is_unknown_rather_than_zero(world, with_forge) -> None:
|
|
98
114
|
"""Branches made here do not track, so this is the normal state for
|
|
99
115
|
exactly the ones a merged request would otherwise reap."""
|
|
@@ -109,6 +125,88 @@ def test_no_upstream_is_unknown_rather_than_zero(world, with_forge) -> None:
|
|
|
109
125
|
assert "no upstream" in row.why
|
|
110
126
|
|
|
111
127
|
|
|
128
|
+
@pytest.fixture
|
|
129
|
+
def gone_upstream(world):
|
|
130
|
+
wt = world.worktree("stacked")
|
|
131
|
+
sha = world.commit("s.txt", "s\n", at=wt)
|
|
132
|
+
_push(world, "stacked", wt)
|
|
133
|
+
world.git(
|
|
134
|
+
"update-ref", "-d", "refs/heads/stacked", sha, at=world.root / "stacked.git"
|
|
135
|
+
)
|
|
136
|
+
world.git("fetch", "--prune", "origin")
|
|
137
|
+
assert world.git("config", "--get", "branch.stacked.merge") == "refs/heads/stacked"
|
|
138
|
+
assert not R.upstream_of("stacked")
|
|
139
|
+
assert R.unpushed_count("stacked") is None
|
|
140
|
+
return wt
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.mark.parametrize("state", ["MERGED", "CLOSED", "OPEN", None])
|
|
144
|
+
def test_a_gone_upstream_requires_a_merged_request(
|
|
145
|
+
world, with_forge, gone_upstream, state
|
|
146
|
+
):
|
|
147
|
+
with_forge([{"number": 12, "state": state}] if state else [])
|
|
148
|
+
rows = verdicts.assess(
|
|
149
|
+
R.worktrees(), "", "refs/heads/main", "main", False, ask_forge=True
|
|
150
|
+
)
|
|
151
|
+
row = next(v for v in rows if v.branch == "stacked")
|
|
152
|
+
assert row.verdict == (verdicts.REMOVE if state == "MERGED" else verdicts.UNKNOWN)
|
|
153
|
+
if state == "MERGED":
|
|
154
|
+
assert row.why == "its pull request #12 is merged and its upstream is gone"
|
|
155
|
+
elif state == "CLOSED":
|
|
156
|
+
assert "upstream is gone" in row.why
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.parametrize("protection", ["dirty", "ignored", "locked", "current"])
|
|
160
|
+
def test_a_merged_request_with_a_gone_upstream_keeps_worktree_protections(
|
|
161
|
+
world, with_forge, gone_upstream, protection
|
|
162
|
+
):
|
|
163
|
+
with_forge([{"number": 12, "state": "MERGED"}])
|
|
164
|
+
if protection == "dirty":
|
|
165
|
+
(gone_upstream / "s.txt").write_text("unfinished\n")
|
|
166
|
+
elif protection == "ignored":
|
|
167
|
+
world.git("config", "core.excludesFile", str(world.root / "ignore"))
|
|
168
|
+
(world.root / "ignore").write_text("cache\n")
|
|
169
|
+
(gone_upstream / "cache").write_text("keep\n")
|
|
170
|
+
elif protection == "locked":
|
|
171
|
+
world.git("worktree", "lock", str(gone_upstream))
|
|
172
|
+
rows = verdicts.assess(
|
|
173
|
+
R.worktrees(),
|
|
174
|
+
"",
|
|
175
|
+
"refs/heads/main",
|
|
176
|
+
"main",
|
|
177
|
+
False,
|
|
178
|
+
here=str(gone_upstream) if protection == "current" else "",
|
|
179
|
+
ask_forge=True,
|
|
180
|
+
)
|
|
181
|
+
row = next(v for v in rows if v.branch == "stacked")
|
|
182
|
+
assert row.verdict == verdicts.KEEP
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_prune_removes_a_merged_branch_with_a_gone_upstream(
|
|
186
|
+
world, with_forge, gone_upstream
|
|
187
|
+
):
|
|
188
|
+
bin_dir = with_forge([{"number": 12, "state": "MERGED"}])
|
|
189
|
+
env = env_for(world)
|
|
190
|
+
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
|
191
|
+
proc = subprocess.run(
|
|
192
|
+
[
|
|
193
|
+
sys.executable,
|
|
194
|
+
"-c",
|
|
195
|
+
"from worktrees.cli import gwp; raise SystemExit(gwp())",
|
|
196
|
+
"--no-fetch",
|
|
197
|
+
"--yes",
|
|
198
|
+
],
|
|
199
|
+
cwd=str(world.repo),
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
env=env,
|
|
203
|
+
)
|
|
204
|
+
assert proc.returncode == 0, proc.stderr
|
|
205
|
+
assert not gone_upstream.exists()
|
|
206
|
+
assert not R.ref_exists("refs/heads/stacked")
|
|
207
|
+
assert world.repo.exists()
|
|
208
|
+
|
|
209
|
+
|
|
112
210
|
def test_an_open_request_decides_nothing(world, with_forge) -> None:
|
|
113
211
|
with_forge([{"number": 12, "state": "OPEN"}])
|
|
114
212
|
wt = world.worktree("live")
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: release
|
|
3
|
-
description: Release a version of this package to PyPI. Bumps the version on a release branch, opens and merges the pull request once its checks pass, then tags the merged commit so publish.yml uploads it. Use when the user says "release", "cut a release", "ship x.y.z", or "/release x.y.z".
|
|
4
|
-
user-invocable: true
|
|
5
|
-
allowed-tools: Bash(git:*), Bash(gh:*), Bash(uv:*), Bash(rt:*), Bash(scripts/*.bash:*)
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# release
|
|
9
|
-
|
|
10
|
-
Release `$ARGUMENTS` of this package. The argument is the version, with or
|
|
11
|
-
without a leading `v`; strip it and use the bare `x.y.z` everywhere below.
|
|
12
|
-
|
|
13
|
-
With no argument, ask which version. Do not guess one from the commits.
|
|
14
|
-
|
|
15
|
-
Every step is one command. Run them in order, read what each says before
|
|
16
|
-
running the next, and stop at the first failure rather than working around
|
|
17
|
-
it. Nothing here is worth improvising: a release that goes wrong is on PyPI
|
|
18
|
-
forever.
|
|
19
|
-
|
|
20
|
-
## 1. Everything that has to be true first
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
git fetch --all --prune
|
|
24
|
-
rt release::prechecks <version> \
|
|
25
|
-
--branch main \
|
|
26
|
-
--check-registry-url "https://pypi.org/pypi/$(uv version | cut -d' ' -f1)/<version>/json"
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
That checks the shape of the version, a clean working tree, that the tag is
|
|
30
|
-
free on the remote, that the version is after the newest release tag, that
|
|
31
|
-
HEAD is on main, and that PyPI does not already carry it. If it refuses, say
|
|
32
|
-
what it said and stop. Do not fix a dirty tree by stashing on the user's
|
|
33
|
-
behalf.
|
|
34
|
-
|
|
35
|
-
`rt` is `releasetools/cli`, installed with `brew install releasetools/tap/releasetools-cli`
|
|
36
|
-
or the installer in its README. Every check it runs refuses when it cannot
|
|
37
|
-
prove what it was asked to prove, so a doubtful answer stops the release
|
|
38
|
-
rather than passing it.
|
|
39
|
-
|
|
40
|
-
## 2. The branch, the notes, and the bump
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
git switch --create release/v<version> --no-track origin/main
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Then run `/release-notes:prepare <version>`, from the ReleaseTools plugin. It
|
|
47
|
-
rules on every commit since the previous tag, writes the body to
|
|
48
|
-
`.git/RELEASE_EDITMSG`, and puts the entry in `CHANGELOG.md` under
|
|
49
|
-
`## <version> - <date>`. It shows the draft and waits before its last step,
|
|
50
|
-
so the user changes it there.
|
|
51
|
-
|
|
52
|
-
Do not write that entry by hand and do not skip it: `publish.yml` reads the
|
|
53
|
-
section back out for the GitHub release, and `build` refuses a tag whose
|
|
54
|
-
version has no section.
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
uv version <version>
|
|
58
|
-
git commit --all --message "Release <version>"
|
|
59
|
-
git push --set-upstream origin refs/heads/release/v<version>
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
`uv version` rewrites `pyproject.toml` and re-locks, so the commit carries
|
|
63
|
-
those two files and `CHANGELOG.md`. Check that with `git show --stat`
|
|
64
|
-
before pushing: a commit
|
|
65
|
-
missing `uv.lock` fails `uv sync --locked` in CI, and it fails after the
|
|
66
|
-
merge rather than before it.
|
|
67
|
-
|
|
68
|
-
Branch from `origin/main` rather than from `main`, so a stale local copy
|
|
69
|
-
cannot become the release.
|
|
70
|
-
|
|
71
|
-
## 3. The pull request, and its checks
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
gh pr create --base main --title "Release <version>" --body "..."
|
|
75
|
-
gh pr checks --watch --fail-fast
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
The body is the changelog entry just written. It is already the summary,
|
|
79
|
-
already reviewed by the reader, and writing a second one invites the two to
|
|
80
|
-
disagree.
|
|
81
|
-
|
|
82
|
-
`--watch` blocks. When it reports a failure, report which check failed and
|
|
83
|
-
its URL, and stop. The branch and the pull request stay; nothing has been
|
|
84
|
-
tagged and nothing published.
|
|
85
|
-
|
|
86
|
-
## 4. Merge, then wait for main
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
gh pr merge --squash --delete-branch
|
|
90
|
-
git switch main && git pull --ff-only
|
|
91
|
-
rt github::await_workflow "$(git rev-parse HEAD)" tests.yml
|
|
92
|
-
```
|
|
93
|
-
|
|
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)`.
|
|
99
|
-
|
|
100
|
-
The wait matters. A squash onto a main that moved is a tree neither the
|
|
101
|
-
branch nor main has tested, and the tag must only ever land on a commit
|
|
102
|
-
already proved green. `github::await_workflow` blocks until `tests.yml` finishes
|
|
103
|
-
on that commit and exits non-zero if it failed.
|
|
104
|
-
|
|
105
|
-
If it failed: `main` now carries the version bump and no release exists. Say
|
|
106
|
-
so plainly. The fix is another pull request, and then this skill again at the
|
|
107
|
-
same version, since no tag was created.
|
|
108
|
-
|
|
109
|
-
## 5. Tag, which is what publishes
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
git tag --annotate v<version> --message "v<version>"
|
|
113
|
-
git push origin refs/tags/v<version>
|
|
114
|
-
gh run watch "$(gh run list --workflow publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Pushing the tag is the release. `publish.yml` re-checks the tag against
|
|
118
|
-
`pyproject.toml`, that the commit is on `main`, and the tests, then builds
|
|
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.
|
|
127
|
-
|
|
128
|
-
Report the PyPI URL and the GitHub release URL when it finishes.
|
|
129
|
-
|
|
130
|
-
## What this never does
|
|
131
|
-
|
|
132
|
-
Move a tag. A tag names one commit forever; a version that needs a second
|
|
133
|
-
attempt gets the next number. If `publish.yml` fails *after* the PyPI upload,
|
|
134
|
-
in `verify` or `release`, re-run it with
|
|
135
|
-
`gh workflow run publish.yml --ref v<version>` rather than re-tagging.
|
|
136
|
-
|
|
137
|
-
Delete a release branch that has not merged, stash the user's work, force
|
|
138
|
-
anything, or retry a failed check by re-running it in the hope it passes.
|
|
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
|