git-worktrees 0.4.2__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 (82) hide show
  1. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/AGENTS.md +4 -4
  2. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/CHANGELOG.md +8 -0
  3. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/PKG-INFO +7 -5
  4. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/README.md +6 -4
  5. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/pyproject.toml +1 -1
  6. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/forge.py +3 -3
  7. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/repo.py +5 -0
  8. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/verdicts.py +16 -6
  9. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_forge.py +98 -0
  10. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/uv.lock +1 -1
  11. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.claude/settings.json +0 -0
  12. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.github/workflows/publish.yml +0 -0
  13. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.github/workflows/testpypi.yml +0 -0
  14. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.github/workflows/tests.yml +0 -0
  15. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.gitignore +0 -0
  16. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.pre-commit-config.yaml +0 -0
  17. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.python-version +0 -0
  18. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/.releasetools.yaml +0 -0
  19. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/LICENSE +0 -0
  20. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gw.fish +0 -0
  21. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwa.fish +0 -0
  22. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwh.fish +0 -0
  23. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwl.fish +0 -0
  24. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwm.fish +0 -0
  25. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwnb.fish +0 -0
  26. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwp.fish +0 -0
  27. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwr.fish +0 -0
  28. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gwrot.fish +0 -0
  29. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/completions/gws.fish +0 -0
  30. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/conf.d/worktrees.fish +0 -0
  31. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/docs/BENCHMARK.md +0 -0
  32. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/docs/DEVELOPMENT.md +0 -0
  33. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/docs/REFERENCE.md +0 -0
  34. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/functions/gwa.fish +0 -0
  35. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/functions/gwl.fish +0 -0
  36. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/functions/gwm.fish +0 -0
  37. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/functions/gwr.fish +0 -0
  38. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/hatch_build.py +0 -0
  39. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/renovate.json +0 -0
  40. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/scripts/README.md +0 -0
  41. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/scripts/benchmark.py +0 -0
  42. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/scripts/verify-publish.bash +0 -0
  43. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/__init__.py +0 -0
  44. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/cli.py +0 -0
  45. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/errors.py +0 -0
  46. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/git.py +0 -0
  47. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/layout.py +0 -0
  48. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/merged.py +0 -0
  49. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/new_branch.py +0 -0
  50. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/pick.py +0 -0
  51. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/prune.py +0 -0
  52. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/render.py +0 -0
  53. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/rotate.py +0 -0
  54. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/src/worktrees/worktree.py +0 -0
  55. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/conftest.py +0 -0
  56. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_benchmark.py +0 -0
  57. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_cli.py +0 -0
  58. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_completions.py +0 -0
  59. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_fixture.py +0 -0
  60. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_gates.py +0 -0
  61. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_new_branch.py +0 -0
  62. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_render.py +0 -0
  63. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_rotate.py +0 -0
  64. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_shims.py +0 -0
  65. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_spec.py +0 -0
  66. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_version.py +0 -0
  67. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/tests/test_worktree.py +0 -0
  68. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gw +0 -0
  69. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwa +0 -0
  70. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwh +0 -0
  71. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwl +0 -0
  72. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwm +0 -0
  73. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwnb +0 -0
  74. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwp +0 -0
  75. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwr +0 -0
  76. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gwrot +0 -0
  77. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/completions/_gws +0 -0
  78. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwa +0 -0
  79. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwl +0 -0
  80. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwm +0 -0
  81. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/functions/gwr +0 -0
  82. {git_worktrees-0.4.2 → git_worktrees-0.4.3}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -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,14 @@ 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
+
20
28
  ## 0.4.2 - 2026-09-22
21
29
 
22
30
  CLI behavior is unchanged from 0.4.1.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-worktrees
3
- Version: 0.4.2
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
@@ -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.2"
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.2"
43
+ version = "0.4.3"
44
44
  source = { editable = "." }
45
45
 
46
46
  [package.optional-dependencies]
File without changes
File without changes