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.
Files changed (84) hide show
  1. git_worktrees-0.4.3/.claude/settings.json +6 -0
  2. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.github/workflows/publish.yml +2 -4
  3. git_worktrees-0.4.3/.releasetools.yaml +23 -0
  4. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/AGENTS.md +4 -4
  5. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/CHANGELOG.md +12 -0
  6. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/PKG-INFO +7 -5
  7. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/README.md +6 -4
  8. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/docs/DEVELOPMENT.md +28 -14
  9. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/pyproject.toml +1 -1
  10. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/forge.py +3 -3
  11. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/repo.py +5 -0
  12. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/verdicts.py +16 -6
  13. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_forge.py +98 -0
  14. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/uv.lock +1 -1
  15. git_worktrees-0.4.1/.agents/skills/release/SKILL.md +0 -138
  16. git_worktrees-0.4.1/.claude/settings.json +0 -5
  17. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.github/workflows/testpypi.yml +0 -0
  18. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.github/workflows/tests.yml +0 -0
  19. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.gitignore +0 -0
  20. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.pre-commit-config.yaml +0 -0
  21. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/.python-version +0 -0
  22. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/LICENSE +0 -0
  23. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gw.fish +0 -0
  24. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwa.fish +0 -0
  25. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwh.fish +0 -0
  26. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwl.fish +0 -0
  27. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwm.fish +0 -0
  28. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwnb.fish +0 -0
  29. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwp.fish +0 -0
  30. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwr.fish +0 -0
  31. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gwrot.fish +0 -0
  32. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/completions/gws.fish +0 -0
  33. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/conf.d/worktrees.fish +0 -0
  34. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/docs/BENCHMARK.md +0 -0
  35. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/docs/REFERENCE.md +0 -0
  36. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwa.fish +0 -0
  37. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwl.fish +0 -0
  38. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwm.fish +0 -0
  39. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/functions/gwr.fish +0 -0
  40. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/hatch_build.py +0 -0
  41. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/renovate.json +0 -0
  42. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/scripts/README.md +0 -0
  43. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/scripts/benchmark.py +0 -0
  44. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/scripts/verify-publish.bash +0 -0
  45. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/__init__.py +0 -0
  46. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/cli.py +0 -0
  47. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/errors.py +0 -0
  48. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/git.py +0 -0
  49. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/layout.py +0 -0
  50. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/merged.py +0 -0
  51. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/new_branch.py +0 -0
  52. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/pick.py +0 -0
  53. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/prune.py +0 -0
  54. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/render.py +0 -0
  55. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/rotate.py +0 -0
  56. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/src/worktrees/worktree.py +0 -0
  57. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/conftest.py +0 -0
  58. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_benchmark.py +0 -0
  59. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_cli.py +0 -0
  60. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_completions.py +0 -0
  61. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_fixture.py +0 -0
  62. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_gates.py +0 -0
  63. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_new_branch.py +0 -0
  64. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_render.py +0 -0
  65. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_rotate.py +0 -0
  66. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_shims.py +0 -0
  67. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_spec.py +0 -0
  68. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_version.py +0 -0
  69. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/tests/test_worktree.py +0 -0
  70. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gw +0 -0
  71. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwa +0 -0
  72. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwh +0 -0
  73. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwl +0 -0
  74. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwm +0 -0
  75. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwnb +0 -0
  76. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwp +0 -0
  77. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwr +0 -0
  78. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwrot +0 -0
  79. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gws +0 -0
  80. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwa +0 -0
  81. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwl +0 -0
  82. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwm +0 -0
  83. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwr +0 -0
  84. {git_worktrees-0.4.1 → git_worktrees-0.4.3}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -0,0 +1,6 @@
1
+ {
2
+ "enabledPlugins": {
3
+ "release-notes@release-tools": true,
4
+ "release@release-tools": true
5
+ }
6
+ }
@@ -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: true
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: true
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 is cross-checked against
304
- `unpushed_count`: it speaks for what reached it, never for commits nobody
305
- pushed, and no upstream at all stays `unknown` rather than becoming zero.
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.1
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 settles it, cross-checked against the
227
- commits an upstream has not got, because a request speaks for what reached
228
- it and nothing about what never did. `--no-forge` decides from git alone, and
229
- so does a run whose fetch just failed.
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 settles it, cross-checked against the
201
- commits an upstream has not got, because a request speaks for what reached
202
- it and nothing about what never did. `--no-forge` decides from git alone, and
203
- so does a run whose fetch just failed.
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
- The skill at `.agents/skills/release/` does the whole of it: cut
159
- `release/v0.2.0` from `origin/main`, `uv version 0.2.0`, open the pull
160
- request, wait for its checks, merge with `--rebase`, wait for main's own run
161
- on the rebased commit, and only then tag. The tag lands on a commit already
162
- proved green, so it never has to move.
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. It
172
- comes from the release-tools marketplace:
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 it in this repository rather than in your own
180
- configuration, so the next person to release is offered the same plugin.
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, and `build`
183
- refuses a tag whose version has no section, before anything is published.
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.1"
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 knows. It is asked last, because it costs a round trip and because
10
- it can be wrong about work pushed after the merge, which is why the answer is
11
- cross-checked against the commits an upstream has not got.
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 speaks for what was pushed. Commits an upstream has not
189
- got were never in it, and a branch with no upstream at all leaves that
190
- unknown rather than zero: branches made here do not track, so "no
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}", REMOVE
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")
@@ -40,7 +40,7 @@ wheels = [
40
40
 
41
41
  [[package]]
42
42
  name = "git-worktrees"
43
- version = "0.4.1"
43
+ version = "0.4.3"
44
44
  source = { editable = "." }
45
45
 
46
46
  [package.optional-dependencies]
@@ -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.
@@ -1,5 +0,0 @@
1
- {
2
- "enabledPlugins": {
3
- "release-notes@release-tools": true
4
- }
5
- }
File without changes
File without changes