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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/AGENTS.md +8 -4
  2. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/CHANGELOG.md +32 -0
  3. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/PKG-INFO +16 -7
  4. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/README.md +15 -6
  5. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/pyproject.toml +1 -1
  6. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/cli.py +22 -9
  7. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/pick.py +17 -1
  8. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/verdicts.py +23 -1
  9. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_cli.py +73 -10
  10. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/uv.lock +1 -1
  11. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.agents/skills/release/SKILL.md +0 -0
  12. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.claude/settings.json +0 -0
  13. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.github/workflows/publish.yml +0 -0
  14. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.github/workflows/testpypi.yml +0 -0
  15. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.github/workflows/tests.yml +0 -0
  16. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.gitignore +0 -0
  17. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.pre-commit-config.yaml +0 -0
  18. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/.python-version +0 -0
  19. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/LICENSE +0 -0
  20. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gw.fish +0 -0
  21. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwa.fish +0 -0
  22. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwh.fish +0 -0
  23. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwl.fish +0 -0
  24. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwm.fish +0 -0
  25. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwnb.fish +0 -0
  26. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwp.fish +0 -0
  27. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwr.fish +0 -0
  28. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gwrot.fish +0 -0
  29. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/completions/gws.fish +0 -0
  30. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/conf.d/worktrees.fish +0 -0
  31. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/docs/BENCHMARK.md +0 -0
  32. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/docs/DEVELOPMENT.md +0 -0
  33. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/functions/gwa.fish +0 -0
  34. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/functions/gwl.fish +0 -0
  35. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/functions/gwm.fish +0 -0
  36. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/functions/gwr.fish +0 -0
  37. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/hatch_build.py +0 -0
  38. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/renovate.json +0 -0
  39. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/scripts/README.md +0 -0
  40. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/scripts/benchmark.py +0 -0
  41. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/scripts/verify-publish.bash +0 -0
  42. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/__init__.py +0 -0
  43. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/errors.py +0 -0
  44. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/forge.py +0 -0
  45. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/git.py +0 -0
  46. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/layout.py +0 -0
  47. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/merged.py +0 -0
  48. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/new_branch.py +0 -0
  49. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/prune.py +0 -0
  50. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/render.py +0 -0
  51. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/repo.py +0 -0
  52. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/rotate.py +0 -0
  53. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/src/worktrees/worktree.py +0 -0
  54. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/conftest.py +0 -0
  55. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_benchmark.py +0 -0
  56. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_completions.py +0 -0
  57. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_fixture.py +0 -0
  58. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_forge.py +0 -0
  59. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_gates.py +0 -0
  60. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_new_branch.py +0 -0
  61. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_render.py +0 -0
  62. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_rotate.py +0 -0
  63. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_shims.py +0 -0
  64. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_spec.py +0 -0
  65. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_version.py +0 -0
  66. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/tests/test_worktree.py +0 -0
  67. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gw +0 -0
  68. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwa +0 -0
  69. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwh +0 -0
  70. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwl +0 -0
  71. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwm +0 -0
  72. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwnb +0 -0
  73. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwp +0 -0
  74. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwr +0 -0
  75. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gwrot +0 -0
  76. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/completions/_gws +0 -0
  77. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/functions/gwa +0 -0
  78. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/functions/gwl +0 -0
  79. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/functions/gwm +0 -0
  80. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/functions/gwr +0 -0
  81. {git_worktrees-0.3.0 → git_worktrees-0.3.1}/zsh/plugins/worktrees/worktrees.plugin.zsh +0 -0
@@ -156,10 +156,14 @@ Two more about what is in the list at all:
156
156
  the one a finished branch leaves you in, so leaving it out leaves out the
157
157
  only answer that is always right. `main` finds it whatever branch it stands
158
158
  on, because its label carries the word, once.
159
- - The worktree you are standing in is listed and never offered. Picking it is
160
- the one answer that takes you nowhere, and a picker whose single candidate
161
- is where you already are prints nothing at all, which reads as a broken
162
- command.
159
+ - The worktree you are standing in is shown and never offered. It carries a
160
+ `*` rather than a number, because picking it is the one answer that takes
161
+ you nowhere. Leaving it out of the list entirely is worse: the list then
162
+ shows fewer rows than `git worktree list` does, and reads as though
163
+ something went missing rather than as where you already are. The main
164
+ checkout is in `gws` and `gwp` for the same reason, kept and never
165
+ proposed, and out of `gwr`'s candidates because `git worktree remove`
166
+ refuses it whatever flag it is given.
163
167
 
164
168
  `--json` and `--list` answer the same question without any of this, and an
165
169
  agent uses those.
@@ -17,6 +17,38 @@ 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.3.1 - 2026-09-20
21
+
22
+ `gws` and `gwp` list the main checkout, and `gwl`'s picker shows the
23
+ worktree you are standing in. Both listings used to leave those rows out, so
24
+ a repository with the main checkout and one worktree showed one row where
25
+ `git worktree list` shows two, which reads as though something went missing
26
+ rather than as where you already are.
27
+
28
+ The main checkout is `keep`, with `it is the main checkout, and never
29
+ removable`. Nothing proposes it, and `git worktree remove` answers
30
+ `fatal: '<path>' is a main working tree` whatever flag it is given, so `gwr`
31
+ still does not offer it.
32
+
33
+ The one you are standing in carries a `*` rather than a number:
34
+
35
+ ```
36
+ * cleanup /home/you/git/.worktrees/cleanup/repo
37
+ 1 docs-ship-with-the-change (main) /home/you/git/repo
38
+ which? [1-1, or blank to cancel]
39
+ ```
40
+
41
+ Two things move with those rows. The counts include the main checkout, so a
42
+ repository with one linked worktree reads `1 removable, 1 kept, 0 unclear`
43
+ where it read `1 removable, 0 kept, 0 unclear`, and `gws --json` and
44
+ `gwp --json` carry the extra verdict. A caller filtering on
45
+ `verdict == "remove"` is unaffected; one reading `verdicts[0]` gets the main
46
+ checkout now.
47
+
48
+ `no worktrees besides the main checkout` is on stderr rather than stdout, and
49
+ says `; gwa NAME makes one`. The table is always on stdout now, so stdout is
50
+ never that sentence instead.
51
+
20
52
  ## 0.3.0 - 2026-09-20
21
53
 
22
54
  `gwl` with no query asks which worktree rather than moving you to one.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-worktrees
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Git worktree commands that refuse to lose work.
5
5
  Project-URL: Bug reports, https://github.com/MihaiBojin/worktrees/issues/new
6
6
  Project-URL: Source, https://github.com/MihaiBojin/worktrees
@@ -168,17 +168,21 @@ separate.
168
168
  ```console
169
169
  $ gws
170
170
  VERDICT BRANCH WHY PATH
171
+ keep main it is the main checkout, and never removable /home/you/git/repo
171
172
  keep dirty-work it has uncommitted changes /home/you/git/.worktrees/dirty-work/repo
172
173
  keep holds-secrets merged, but holds 2 ignored path(s); pass --delete-ignored /home/you/git/.worktrees/holds-secrets/repo
173
174
  unknown never-pushed not merged into origin/main, and no upstream says whether its commits were pushed /home/you/git/.worktrees/never-pushed/repo
174
175
  remove squash-merged squash-merged /home/you/git/.worktrees/squash-merged/repo
175
176
 
176
- 1 removable, 2 kept, 1 unclear
177
+ 1 removable, 3 kept, 1 unclear
177
178
  unclear, and gwp does not touch these: never-pushed
178
179
  gwp removes the 1 marked removable
179
180
  ```
180
181
 
181
- Rows come in `git worktree list` order. Paths are absolute wherever this
182
+ Rows come in `git worktree list` order, the main checkout first. It is there
183
+ so the table shows what `git worktree list` shows; nothing proposes it, and
184
+ `git worktree remove` answers `fatal: '<path>' is a main working tree`
185
+ whatever flag it is given. Paths are absolute wherever this
182
186
  prints one, so a row can be pasted into the next command. The table and the
183
187
  count are on stdout; the two lines after them are on stderr.
184
188
 
@@ -326,13 +330,18 @@ show me the options, and being moved without being asked is not that. So it
326
330
  asks, numbered, however many there are:
327
331
 
328
332
  ```console
329
- $ gwl # standing in the main checkout
330
- 1 dirty-work /home/you/git/.worktrees/dirty-work/repo
331
- 2 holds-secrets /home/you/git/.worktrees/holds-secrets/repo
332
- 3 never-pushed /home/you/git/.worktrees/never-pushed/repo
333
+ $ gwl # standing in never-pushed
334
+ * never-pushed /home/you/git/.worktrees/never-pushed/repo
335
+ 1 main /home/you/git/repo
336
+ 2 dirty-work /home/you/git/.worktrees/dirty-work/repo
337
+ 3 holds-secrets /home/you/git/.worktrees/holds-secrets/repo
333
338
  which? [1-3, or blank to cancel]
334
339
  ```
335
340
 
341
+ The one you are standing in is marked `*` and carries no number. It is where
342
+ you are rather than somewhere to go, and a list without it shows three rows
343
+ where `git worktree list` shows four.
344
+
336
345
  Matching is substring first and then subsequence, so `tst` finds `add-tests`,
337
346
  and a substring hit always outranks a loose one. Without a terminal `gwl`
338
347
  refuses at exit 3 and names `--list` and `--json`.
@@ -142,17 +142,21 @@ separate.
142
142
  ```console
143
143
  $ gws
144
144
  VERDICT BRANCH WHY PATH
145
+ keep main it is the main checkout, and never removable /home/you/git/repo
145
146
  keep dirty-work it has uncommitted changes /home/you/git/.worktrees/dirty-work/repo
146
147
  keep holds-secrets merged, but holds 2 ignored path(s); pass --delete-ignored /home/you/git/.worktrees/holds-secrets/repo
147
148
  unknown never-pushed not merged into origin/main, and no upstream says whether its commits were pushed /home/you/git/.worktrees/never-pushed/repo
148
149
  remove squash-merged squash-merged /home/you/git/.worktrees/squash-merged/repo
149
150
 
150
- 1 removable, 2 kept, 1 unclear
151
+ 1 removable, 3 kept, 1 unclear
151
152
  unclear, and gwp does not touch these: never-pushed
152
153
  gwp removes the 1 marked removable
153
154
  ```
154
155
 
155
- Rows come in `git worktree list` order. Paths are absolute wherever this
156
+ Rows come in `git worktree list` order, the main checkout first. It is there
157
+ so the table shows what `git worktree list` shows; nothing proposes it, and
158
+ `git worktree remove` answers `fatal: '<path>' is a main working tree`
159
+ whatever flag it is given. Paths are absolute wherever this
156
160
  prints one, so a row can be pasted into the next command. The table and the
157
161
  count are on stdout; the two lines after them are on stderr.
158
162
 
@@ -300,13 +304,18 @@ show me the options, and being moved without being asked is not that. So it
300
304
  asks, numbered, however many there are:
301
305
 
302
306
  ```console
303
- $ gwl # standing in the main checkout
304
- 1 dirty-work /home/you/git/.worktrees/dirty-work/repo
305
- 2 holds-secrets /home/you/git/.worktrees/holds-secrets/repo
306
- 3 never-pushed /home/you/git/.worktrees/never-pushed/repo
307
+ $ gwl # standing in never-pushed
308
+ * never-pushed /home/you/git/.worktrees/never-pushed/repo
309
+ 1 main /home/you/git/repo
310
+ 2 dirty-work /home/you/git/.worktrees/dirty-work/repo
311
+ 3 holds-secrets /home/you/git/.worktrees/holds-secrets/repo
307
312
  which? [1-3, or blank to cancel]
308
313
  ```
309
314
 
315
+ The one you are standing in is marked `*` and carries no number. It is where
316
+ you are rather than somewhere to go, and a list without it shows three rows
317
+ where `git worktree list` shows four.
318
+
310
319
  Matching is substring first and then subsequence, so `tst` finds `add-tests`,
311
320
  and a substring hit always outranks a loose one. Without a terminal `gwl`
312
321
  refuses at exit 3 and names `--list` and `--json`.
@@ -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.3.0"
12
+ version = "0.3.1"
13
13
  # The standard library answers every question this asks. A dependency here
14
14
  # would be paid on every invocation, and an invocation is 67 ms of which 25 ms
15
15
  # is the interpreter starting.
@@ -193,7 +193,15 @@ def _assess(
193
193
  judge(only, head, head_branch, ignored, ask_forge=ask_forge)
194
194
  if judge
195
195
  else verdicts.assess(
196
- records, only, head, head_branch, ignored, ask_forge=ask_forge
196
+ records,
197
+ only,
198
+ head,
199
+ head_branch,
200
+ ignored,
201
+ ask_forge=ask_forge,
202
+ # `gws` and `gwp` report; `gwr` acts, and takes this list as its
203
+ # candidates, so it asks through `judge` and gets it without.
204
+ include_main=True,
197
205
  )
198
206
  )
199
207
  return rows, verdicts.stale(records), head, head_branch
@@ -247,16 +255,15 @@ def run_status(args: argparse.Namespace) -> int:
247
255
  )
248
256
  return 0
249
257
 
250
- if not rows:
251
- print("no worktrees besides the main checkout")
252
- else:
253
- print(_verdict_table(rows))
258
+ print(_verdict_table(rows))
254
259
 
255
260
  if args.quiet:
256
261
  return 0
257
262
 
258
263
  print()
259
264
  print(_counted(rows))
265
+ if len(rows) < 2:
266
+ _err("no worktrees besides the main checkout; gwa NAME makes one")
260
267
  if stale:
261
268
  _err(
262
269
  f"{len(stale)} stale record(s) for directories that are gone; "
@@ -300,13 +307,13 @@ def run_prune(args: argparse.Namespace) -> int:
300
307
  # command that would have printed it.
301
308
  if args.json:
302
309
  print(json.dumps({"removed": [], "failed": 0}, indent=2))
303
- elif not rows:
304
- print("no worktrees besides the main checkout")
305
310
  else:
306
311
  print(_verdict_table(rows))
307
312
  if not args.quiet:
308
313
  print()
309
314
  print(f"nothing to remove; {_counted(rows)}")
315
+ if len(rows) < 2:
316
+ _err("no worktrees besides the main checkout; gwa NAME makes one")
310
317
  return 0
311
318
 
312
319
  if unknown and not args.quiet:
@@ -663,8 +670,14 @@ def run_list(args: argparse.Namespace) -> int:
663
670
  return 0
664
671
 
665
672
  # A query that narrows to one has said which. No query has not, even
666
- # when the repository holds exactly one other worktree.
667
- chosen = pick.choose(elsewhere, _err, outright=bool(args.query))
673
+ # when the repository holds exactly one other worktree. The one you are
674
+ # standing in is shown above them, marked and unnumbered.
675
+ chosen = pick.choose(
676
+ elsewhere,
677
+ _err,
678
+ outright=bool(args.query),
679
+ standing_in=next((w for w in found if _same(w.path, here)), None),
680
+ )
668
681
  if chosen is None:
669
682
  _err("nothing picked")
670
683
  return 0
@@ -69,6 +69,7 @@ def choose(
69
69
  show: Callable[[str], None],
70
70
  ask: Callable[[str], str] | None = None,
71
71
  outright: bool = True,
72
+ standing_in: Worktree | None = None,
72
73
  ) -> Worktree | None:
73
74
  """Ask which one, and None means cancelled.
74
75
 
@@ -77,6 +78,11 @@ def choose(
77
78
  a request to be shown the options, and being moved without being asked
78
79
  because there happened to be one other worktree is not that.
79
80
 
81
+ `standing_in` is shown above the numbered ones, marked `*` and carrying
82
+ no number. It is not somewhere to go, and a list that leaves it out shows
83
+ one row where `git worktree list` shows two, which reads as though
84
+ something went missing rather than as where you already are.
85
+
80
86
  A run whose stdin is not a terminal is refused rather than left to block:
81
87
  an agent or a pipe reaching a prompt would hang, and --json answers the
82
88
  same question without one.
@@ -96,7 +102,17 @@ def choose(
96
102
  )
97
103
  ask = _prompt
98
104
 
99
- width = max(len(wt.label) for wt in candidates)
105
+ listed = [*([standing_in] if standing_in is not None else []), *candidates]
106
+ width = max(len(wt.label) for wt in listed)
107
+ if standing_in is not None:
108
+ # Padded before it is painted, or the escape codes count as width and
109
+ # every column under it sits crooked.
110
+ mark = f"{'*':>3}"
111
+ show(
112
+ f"{render.err(mark, render.DIM)} "
113
+ f"{render.err(standing_in.label.ljust(width), render.DIM)} "
114
+ f"{render.err(standing_in.path, render.DIM)}"
115
+ )
100
116
  for i, wt in enumerate(candidates, 1):
101
117
  show(
102
118
  f"{render.err(f'{i:>3}', render.DIM)} "
@@ -74,6 +74,7 @@ def assess(
74
74
  repo: str | os.PathLike[str] | None = None,
75
75
  here: str | None = None,
76
76
  ask_forge: bool = False,
77
+ include_main: bool = False,
77
78
  ) -> list[Verdict]:
78
79
  """Every worktree, in the order `git worktree remove` would refuse them.
79
80
 
@@ -83,6 +84,11 @@ def assess(
83
84
  `here` is the checkout the caller is standing in, which is kept rather
84
85
  than proposed. Pass "" to judge it like any other: `gwr` is asked for one
85
86
  by name and steps out of it first, where a listing has no way to.
87
+
88
+ `include_main` puts the main checkout in the list, kept and never
89
+ proposed. A listing leaving it out shows one row where `git worktree
90
+ list` shows two, and reads as though something went missing. `gwr` takes
91
+ the list as its candidates, so it asks for the list without it.
86
92
  """
87
93
  main = R.main_worktree(repo)
88
94
  if here is None:
@@ -91,10 +97,26 @@ def assess(
91
97
 
92
98
  out: list[Verdict] = []
93
99
  for wt in records:
94
- if "bare" in wt.flags or "prunable" in wt.flags or wt.path == main:
100
+ if "bare" in wt.flags or "prunable" in wt.flags:
95
101
  continue
96
102
  if only and wt.branch != only:
97
103
  continue
104
+ if wt.path == main:
105
+ # Said before the standing-in rule below, because which one is
106
+ # the main checkout is the more useful of the two facts and the
107
+ # only one that is true from anywhere.
108
+ if include_main:
109
+ out.append(
110
+ Verdict(
111
+ KEEP,
112
+ wt.branch,
113
+ wt.path,
114
+ "it is the main checkout, and never removable",
115
+ 0,
116
+ wt.sha,
117
+ )
118
+ )
119
+ continue
98
120
 
99
121
  def say(verdict: str, why: str, ignored: int = 0, wt: R.Worktree = wt) -> None:
100
122
  out.append(Verdict(verdict, wt.branch, wt.path, why, ignored, wt.sha))
@@ -68,8 +68,21 @@ def run_with_a_terminal_on_stdout(
68
68
  return proc.returncode, b"".join(chunks).decode().replace("\r\n", "\n")
69
69
 
70
70
 
71
+ def run_on_a_terminal_at(
72
+ world, at: Path, entry: str, *args: str, answer: str
73
+ ) -> tuple[int, str]:
74
+ """The same, from a directory that is not the main checkout."""
75
+ return _on_a_terminal(world, at, entry, *args, answer=answer)
76
+
77
+
71
78
  def run_on_a_terminal(world, entry: str, *args: str, answer: str) -> tuple[int, str]:
72
79
  """The same, with a real tty on stdin, so the prompt is reachable."""
80
+ return _on_a_terminal(world, world.repo, entry, *args, answer=answer)
81
+
82
+
83
+ def _on_a_terminal(
84
+ world, at: Path, entry: str, *args: str, answer: str
85
+ ) -> tuple[int, str]:
73
86
  parent, child = pty.openpty()
74
87
  proc = subprocess.Popen(
75
88
  [
@@ -78,7 +91,7 @@ def run_on_a_terminal(world, entry: str, *args: str, answer: str) -> tuple[int,
78
91
  f"from worktrees.cli import {entry}; raise SystemExit({entry}())",
79
92
  *args,
80
93
  ],
81
- cwd=str(world.repo),
94
+ cwd=str(at),
82
95
  stdin=child,
83
96
  stdout=subprocess.PIPE,
84
97
  stderr=subprocess.STDOUT,
@@ -113,16 +126,19 @@ def test_status_leaves_a_removable_worktree_alone(world) -> None:
113
126
  p = run(world, "gws", "--no-fetch")
114
127
  assert p.returncode == 0, p.stderr
115
128
  assert "remove" in p.stdout
116
- assert "1 removable, 0 kept, 0 unclear" in p.stdout
129
+ assert "1 removable, 1 kept, 0 unclear" in p.stdout # the main checkout
117
130
  assert "gwp removes the 1 marked removable" in p.stderr
118
131
  assert wt.exists()
119
132
  assert world.git("rev-parse", "--verify", "refs/heads/done")
120
133
 
121
134
 
122
135
  def test_status_says_when_there_is_nothing(world) -> None:
136
+ """The main checkout is still a row, so the table shows what
137
+ `git worktree list` shows and the hint says what is missing."""
123
138
  p = run(world, "gws", "--no-fetch")
124
139
  assert p.returncode == 0, p.stderr
125
- assert "no worktrees besides the main checkout" in p.stdout
140
+ assert "it is the main checkout" in p.stdout
141
+ assert "no worktrees besides the main checkout; gwa NAME makes one" in p.stderr
126
142
 
127
143
 
128
144
  def test_status_json_is_data_on_stdout(world) -> None:
@@ -131,7 +147,9 @@ def test_status_json_is_data_on_stdout(world) -> None:
131
147
  assert p.returncode == 0, p.stderr
132
148
  payload = json.loads(p.stdout)
133
149
  assert payload["head"] == "refs/heads/main"
134
- assert payload["verdicts"][0]["verdict"] == "remove"
150
+ rows = {v["branch"]: v for v in payload["verdicts"]}
151
+ assert rows["done"]["verdict"] == "remove"
152
+ assert rows["main"]["why"] == "it is the main checkout, and never removable"
135
153
  assert payload["stale"] == []
136
154
 
137
155
 
@@ -147,9 +165,11 @@ def test_json_carries_the_ignored_count_as_a_number(world) -> None:
147
165
 
148
166
  p = run(world, "gws", "--no-fetch", "--no-forge", "--json")
149
167
  assert p.returncode == 0, p.stderr
150
- row = json.loads(p.stdout)["verdicts"][0]
168
+ rows = {v["branch"]: v for v in json.loads(p.stdout)["verdicts"]}
169
+ row = rows["holds-secrets"]
151
170
  assert row["ignored"] == 1
152
171
  assert row["verdict"] == "keep"
172
+ assert rows["main"]["ignored"] == 0
153
173
  # and the sentence still says it, for the person reading the table
154
174
  assert "1 ignored path(s)" in row["why"]
155
175
 
@@ -312,7 +332,8 @@ def test_prune_clears_a_stale_record(world) -> None:
312
332
  def test_prune_says_so_when_there_is_nothing(world) -> None:
313
333
  p = run(world, "gwp", "--no-fetch", "--yes")
314
334
  assert p.returncode == 0, p.stderr
315
- assert "no worktrees besides the main checkout" in p.stdout
335
+ assert "it is the main checkout" in p.stdout
336
+ assert "no worktrees besides the main checkout; gwa NAME makes one" in p.stderr
316
337
 
317
338
 
318
339
  def test_prune_prints_the_reason_rather_than_naming_the_command(world) -> None:
@@ -324,7 +345,7 @@ def test_prune_prints_the_reason_rather_than_naming_the_command(world) -> None:
324
345
  assert p.returncode == 0, p.stderr
325
346
  assert "busy" in p.stdout
326
347
  assert "it has uncommitted changes" in p.stdout
327
- assert "nothing to remove; 0 removable, 1 kept, 0 unclear" in p.stdout
348
+ assert "nothing to remove; 0 removable, 2 kept, 0 unclear" in p.stdout
328
349
  assert "gws" not in p.stdout
329
350
 
330
351
 
@@ -390,7 +411,7 @@ def test_the_cli_name_takes_a_subcommand(world) -> None:
390
411
  world.worktree("done")
391
412
  p = run(world, "main", "status", "--no-fetch", "--json")
392
413
  assert p.returncode == 0, p.stderr
393
- assert json.loads(p.stdout)["verdicts"][0]["branch"] == "done"
414
+ assert "done" in {v["branch"] for v in json.loads(p.stdout)["verdicts"]}
394
415
 
395
416
 
396
417
  def test_the_cli_name_with_no_argument_prints_help(world) -> None:
@@ -437,7 +458,7 @@ def test_every_shorthand_reaches_the_same_command(world, spelling: str) -> None:
437
458
  world.worktree("done")
438
459
  p = run(world, "main", spelling, "--no-fetch", "--json")
439
460
  assert p.returncode == 0, p.stderr
440
- assert json.loads(p.stdout)["verdicts"][0]["branch"] == "done"
461
+ assert "done" in {v["branch"] for v in json.loads(p.stdout)["verdicts"]}
441
462
 
442
463
 
443
464
  @pytest.mark.parametrize(
@@ -560,7 +581,7 @@ def test_json_is_never_coloured_even_on_a_terminal(world) -> None:
560
581
  code, out = run_with_a_terminal_on_stdout(world, "gws", "--no-fetch", "--json")
561
582
  assert code == 0, out
562
583
  assert "[" not in out
563
- assert json.loads(out)["verdicts"][0]["branch"] == "done"
584
+ assert "done" in {v["branch"] for v in json.loads(out)["verdicts"]}
564
585
 
565
586
 
566
587
  def test_no_color_turns_it_off_on_a_terminal(world) -> None:
@@ -696,3 +717,45 @@ def test_list_with_no_query_takes_blank_as_cancelled(world) -> None:
696
717
  code, out = run_on_a_terminal(world, "gwl", answer="\n")
697
718
  assert code == 0, out
698
719
  assert "nothing picked" in out
720
+
721
+
722
+ def test_status_lists_the_main_checkout_and_never_proposes_it(world) -> None:
723
+ """A listing that leaves it out shows one row where `git worktree list`
724
+ shows two, which reads as though something went missing."""
725
+ world.worktree("done")
726
+ p = run(world, "gws", "--no-fetch", "--no-forge")
727
+ assert p.returncode == 0, p.stderr
728
+ assert "it is the main checkout, and never removable" in p.stdout
729
+ assert p.stdout.count("keep") == 1
730
+ assert "1 removable, 1 kept, 0 unclear" in p.stdout
731
+ rows = {
732
+ v["branch"]: v
733
+ for v in json.loads(
734
+ run(world, "gws", "--no-fetch", "--no-forge", "--json").stdout
735
+ )["verdicts"]
736
+ }
737
+ assert set(rows) == {"main", "done"}
738
+ assert rows["main"]["verdict"] == "keep"
739
+
740
+
741
+ def test_remove_is_never_offered_the_main_checkout(world) -> None:
742
+ """`gwr` takes the same list as its candidates, and `git worktree remove`
743
+ answers `fatal: '<path>' is a main working tree` whatever flag it is
744
+ given, so offering it would propose something certain to be refused.
745
+ """
746
+ world.worktree("done")
747
+ p = run(world, "gwr", "--no-fetch", "--complete")
748
+ assert p.returncode == 0, p.stderr
749
+ assert "main" not in p.stdout
750
+ assert "done" in p.stdout
751
+
752
+
753
+ def test_list_shows_the_worktree_you_are_standing_in_unnumbered(world) -> None:
754
+ """Marked, above the ones you can go to, and carrying no number: it is
755
+ where you are rather than somewhere to go."""
756
+ wt = world.worktree("cleanup")
757
+ code, out = run_on_a_terminal_at(world, wt, "gwl", answer="\n")
758
+ assert code == 0, out
759
+ assert " * cleanup" in out
760
+ assert " 1 main" in out
761
+ assert "which? [1-1, or blank to cancel]" in out
@@ -40,7 +40,7 @@ wheels = [
40
40
 
41
41
  [[package]]
42
42
  name = "git-worktrees"
43
- version = "0.3.0"
43
+ version = "0.3.1"
44
44
  source = { editable = "." }
45
45
 
46
46
  [package.optional-dependencies]
File without changes
File without changes