git-worktrees 0.1.0__py3-none-any.whl
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.1.0.dist-info/METADATA +335 -0
- git_worktrees-0.1.0.dist-info/RECORD +18 -0
- git_worktrees-0.1.0.dist-info/WHEEL +4 -0
- git_worktrees-0.1.0.dist-info/entry_points.txt +11 -0
- git_worktrees-0.1.0.dist-info/licenses/LICENSE +201 -0
- worktrees/__init__.py +8 -0
- worktrees/cli.py +752 -0
- worktrees/forge.py +115 -0
- worktrees/git.py +302 -0
- worktrees/layout.py +52 -0
- worktrees/merged.py +103 -0
- worktrees/new_branch.py +79 -0
- worktrees/pick.py +105 -0
- worktrees/prune.py +170 -0
- worktrees/repo.py +323 -0
- worktrees/rotate.py +142 -0
- worktrees/verdicts.py +161 -0
- worktrees/worktree.py +221 -0
worktrees/cli.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""Argument parsing and output. Data on stdout, diagnostics on stderr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__, new_branch, pick, prune, verdicts
|
|
12
|
+
from . import repo as R
|
|
13
|
+
from . import rotate as rotate_mod
|
|
14
|
+
from . import worktree as wt_mod
|
|
15
|
+
from .git import GitError, Refused, commands, options
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _err(text: str) -> None:
|
|
19
|
+
print(text, file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pretty(path: str) -> str:
|
|
23
|
+
home = str(Path.home())
|
|
24
|
+
return "~" + path[len(home) :] if path.startswith(home + "/") else path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _table(rows: Sequence[tuple[str, ...]]) -> str:
|
|
28
|
+
"""Columns wide enough for their content, the last one unpadded."""
|
|
29
|
+
if not rows:
|
|
30
|
+
return ""
|
|
31
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
|
|
32
|
+
out = []
|
|
33
|
+
for row in rows:
|
|
34
|
+
cells = [c.ljust(widths[i]) for i, c in enumerate(row[:-1])]
|
|
35
|
+
out.append(" ".join([*cells, row[-1]]).rstrip())
|
|
36
|
+
return "\n".join(out)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _explain() -> int:
|
|
40
|
+
"""Every git command the program can issue."""
|
|
41
|
+
rows = [("", "COMMAND", "GIT")]
|
|
42
|
+
for c in commands():
|
|
43
|
+
rows.append(("!" if c.mutates else " ", c.name, "git " + " ".join(c.shape)))
|
|
44
|
+
print(_table(rows))
|
|
45
|
+
print()
|
|
46
|
+
print(
|
|
47
|
+
"! takes the repository's shared refs and runs serially. The guard "
|
|
48
|
+
"refuses\n reset --hard, a forced checkout or switch, clean -f, push "
|
|
49
|
+
"--force,\n worktree remove --force and branch -D outright, whatever "
|
|
50
|
+
"flags are passed."
|
|
51
|
+
)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# the assessment both commands share
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _add_assess_flags(p: argparse.ArgumentParser) -> None:
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--branch", default="", metavar="NAME", help="consider only that branch"
|
|
63
|
+
)
|
|
64
|
+
p.add_argument("--no-fetch", action="store_true", help="use the refs already here")
|
|
65
|
+
p.add_argument(
|
|
66
|
+
"--delete-ignored",
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="count a worktree holding gitignored files as removable; nothing "
|
|
69
|
+
"restores them",
|
|
70
|
+
)
|
|
71
|
+
p.add_argument("--json", action="store_true", help="verdicts as data")
|
|
72
|
+
p.add_argument("-q", "--quiet", action="store_true", help="verdicts only")
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"-v", "--verbose", action="store_true", help="print every git command"
|
|
75
|
+
)
|
|
76
|
+
p.add_argument(
|
|
77
|
+
"--no-forge",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="decide from git alone; never ask the forge",
|
|
80
|
+
)
|
|
81
|
+
p.add_argument(
|
|
82
|
+
"--explain", action="store_true", help="print every git command and exit"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Stop(Exception):
|
|
87
|
+
"""A refusal with an exit code, raised where the reason is known."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, code: int) -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.code = code
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _assess(
|
|
95
|
+
args: argparse.Namespace,
|
|
96
|
+
judge: Callable[..., list[verdicts.Verdict]] | None = None,
|
|
97
|
+
) -> tuple[list[verdicts.Verdict], list[R.Worktree], str]:
|
|
98
|
+
"""Fetch, resolve the head branch, and judge every worktree.
|
|
99
|
+
|
|
100
|
+
The one path every command takes, so `prune` can only ever act on what
|
|
101
|
+
`status` printed. `judge` is how `remove` asks for the one you stand in
|
|
102
|
+
to be judged like any other, having stepped out of it first.
|
|
103
|
+
"""
|
|
104
|
+
remote = R.remote()
|
|
105
|
+
if not args.no_fetch:
|
|
106
|
+
if remote:
|
|
107
|
+
R.fetch(remote)
|
|
108
|
+
elif not args.quiet:
|
|
109
|
+
_err("using the refs already here; they may be stale (--no-fetch)")
|
|
110
|
+
|
|
111
|
+
head, warning = R.head_ref(remote, online=not args.no_fetch)
|
|
112
|
+
if warning and not args.quiet:
|
|
113
|
+
_err(warning)
|
|
114
|
+
if not head:
|
|
115
|
+
raise _Stop("cannot tell which branch this repository branches from", 2)
|
|
116
|
+
head_branch = R.ref_name(head)
|
|
117
|
+
if remote:
|
|
118
|
+
head_branch = head_branch.removeprefix(remote + "/")
|
|
119
|
+
|
|
120
|
+
only = getattr(args, "branch", "")
|
|
121
|
+
ignored = getattr(args, "delete_ignored", False)
|
|
122
|
+
records = R.worktrees()
|
|
123
|
+
# The forge is asked last and only where git could not tell. It costs a
|
|
124
|
+
# round trip, and a branch merged as part of a stack is the one case
|
|
125
|
+
# content cannot answer: its changes reach the head branch across several
|
|
126
|
+
# squashes, so a stale intermediate and real work look alike to a diff.
|
|
127
|
+
ask_forge = not getattr(args, "no_forge", False)
|
|
128
|
+
rows = (
|
|
129
|
+
judge(only, head, head_branch, ignored, ask_forge=ask_forge)
|
|
130
|
+
if judge
|
|
131
|
+
else verdicts.assess(
|
|
132
|
+
records, only, head, head_branch, ignored, ask_forge=ask_forge
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return rows, verdicts.stale(records), head
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------
|
|
139
|
+
# status
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_status(args: argparse.Namespace) -> int:
|
|
144
|
+
"""Say what is here. Nothing in this path removes anything."""
|
|
145
|
+
options.verbose = args.verbose
|
|
146
|
+
if args.explain:
|
|
147
|
+
return _explain()
|
|
148
|
+
|
|
149
|
+
rows, stale, head = _assess(args)
|
|
150
|
+
go = prune.removable(rows)
|
|
151
|
+
unknown = [v for v in rows if v.verdict == verdicts.UNKNOWN]
|
|
152
|
+
|
|
153
|
+
if args.json:
|
|
154
|
+
print(
|
|
155
|
+
json.dumps(
|
|
156
|
+
{
|
|
157
|
+
"head": head,
|
|
158
|
+
"stale": [w.path for w in stale],
|
|
159
|
+
"verdicts": [
|
|
160
|
+
{
|
|
161
|
+
"verdict": v.verdict,
|
|
162
|
+
"branch": v.branch,
|
|
163
|
+
"path": v.path,
|
|
164
|
+
"why": v.why,
|
|
165
|
+
}
|
|
166
|
+
for v in rows
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
indent=2,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
if not rows:
|
|
175
|
+
print("no worktrees besides the main checkout")
|
|
176
|
+
else:
|
|
177
|
+
table: list[tuple[str, ...]] = [("VERDICT", "BRANCH", "WHY", "PATH")]
|
|
178
|
+
table += [(v.verdict, v.label, v.why, _pretty(v.path)) for v in rows]
|
|
179
|
+
print(_table(table))
|
|
180
|
+
|
|
181
|
+
if args.quiet:
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
print()
|
|
185
|
+
kept = len(rows) - len(go) - len(unknown)
|
|
186
|
+
print(f"{len(go)} removable, {kept} kept, {len(unknown)} unclear")
|
|
187
|
+
if stale:
|
|
188
|
+
_err(
|
|
189
|
+
f"{len(stale)} stale record(s) for directories that are gone; "
|
|
190
|
+
"gwp clears them"
|
|
191
|
+
)
|
|
192
|
+
if unknown:
|
|
193
|
+
names = ", ".join(v.label for v in unknown)
|
|
194
|
+
_err(f"unclear, and gwp does not touch these: {names}")
|
|
195
|
+
if go:
|
|
196
|
+
_err(f"gwp removes the {len(go)} marked removable")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------
|
|
201
|
+
# prune
|
|
202
|
+
# --------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run_prune(args: argparse.Namespace) -> int:
|
|
206
|
+
"""Remove exactly what status marks removable, having asked first."""
|
|
207
|
+
options.verbose = args.verbose
|
|
208
|
+
if args.explain:
|
|
209
|
+
return _explain()
|
|
210
|
+
|
|
211
|
+
# git's own bookkeeping for worktrees whose directories somebody removed
|
|
212
|
+
# by hand. A stale record answers for a directory that is not there, and
|
|
213
|
+
# neither command may propose removing one.
|
|
214
|
+
prune.worktree_prune()
|
|
215
|
+
|
|
216
|
+
rows, _, _ = _assess(args)
|
|
217
|
+
go = prune.removable(rows)
|
|
218
|
+
unknown = [v for v in rows if v.verdict == verdicts.UNKNOWN]
|
|
219
|
+
|
|
220
|
+
if unknown and not args.quiet:
|
|
221
|
+
names = ", ".join(v.label for v in unknown)
|
|
222
|
+
_err(f"unclear, and this does not touch them: {names}")
|
|
223
|
+
|
|
224
|
+
if not go:
|
|
225
|
+
if not args.json:
|
|
226
|
+
print("nothing to remove; gws says why")
|
|
227
|
+
else:
|
|
228
|
+
print(json.dumps({"removed": [], "failed": 0}, indent=2))
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
# What goes, and what puts it back, before anything does. On stderr with
|
|
232
|
+
# the rest of the diagnostics, so stdout carries the result alone;
|
|
233
|
+
# --quiet and --yes do not silence it.
|
|
234
|
+
prune.plan(go, args.delete_ignored, _err)
|
|
235
|
+
|
|
236
|
+
if not args.yes:
|
|
237
|
+
try:
|
|
238
|
+
if not prune.confirm(len(go)):
|
|
239
|
+
_err("nothing removed")
|
|
240
|
+
return 0
|
|
241
|
+
except Refused as exc:
|
|
242
|
+
_err(str(exc))
|
|
243
|
+
return 2
|
|
244
|
+
|
|
245
|
+
failed = prune.sweep(go, args.delete_ignored, _err)
|
|
246
|
+
removed = [v.label for v in go]
|
|
247
|
+
if args.json:
|
|
248
|
+
print(json.dumps({"removed": removed, "failed": failed}, indent=2))
|
|
249
|
+
elif failed:
|
|
250
|
+
_err(f"{failed} of {len(go)} could not be removed; each said why above")
|
|
251
|
+
else:
|
|
252
|
+
print(f"removed {len(go)} worktree(s)")
|
|
253
|
+
return 1 if failed else 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --------------------------------------------------------------------------
|
|
257
|
+
# new-branch
|
|
258
|
+
# --------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _add_new_branch_flags(p: argparse.ArgumentParser) -> None:
|
|
262
|
+
p.add_argument(
|
|
263
|
+
"name", nargs="?", default="", metavar="NAME", help="the branch to start"
|
|
264
|
+
)
|
|
265
|
+
p.add_argument(
|
|
266
|
+
"--no-fetch", action="store_true", help="branch off what is already here"
|
|
267
|
+
)
|
|
268
|
+
p.add_argument("--json", action="store_true", help="the result as data")
|
|
269
|
+
p.add_argument("-q", "--quiet", action="store_true", help="say nothing on success")
|
|
270
|
+
p.add_argument(
|
|
271
|
+
"-v", "--verbose", action="store_true", help="print every git command"
|
|
272
|
+
)
|
|
273
|
+
p.add_argument(
|
|
274
|
+
"--explain", action="store_true", help="print every git command and exit"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def run_new_branch(args: argparse.Namespace) -> int:
|
|
279
|
+
options.verbose = args.verbose
|
|
280
|
+
if args.explain:
|
|
281
|
+
return _explain()
|
|
282
|
+
if not args.name:
|
|
283
|
+
# The program the caller typed, so `worktrees new-branch` does not
|
|
284
|
+
# answer with the name of its own alias.
|
|
285
|
+
_err(f"usage: {args.prog} NAME")
|
|
286
|
+
return 2
|
|
287
|
+
|
|
288
|
+
warn = None if args.quiet else _err
|
|
289
|
+
if args.no_fetch and not args.quiet:
|
|
290
|
+
_err("branching from what is already here; refs may be stale (--no-fetch)")
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
started = new_branch.create(args.name, fetch=not args.no_fetch, warn=warn)
|
|
294
|
+
except new_branch.Refusal as exc:
|
|
295
|
+
_err(str(exc))
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
if args.json:
|
|
299
|
+
print(
|
|
300
|
+
json.dumps(
|
|
301
|
+
{"branch": started.branch, "base": started.base, "sha": started.sha},
|
|
302
|
+
indent=2,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
elif not args.quiet:
|
|
306
|
+
print(f"{started.branch} from {R.ref_name(started.base)} at {started.sha}")
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# --------------------------------------------------------------------------
|
|
311
|
+
# rotate
|
|
312
|
+
# --------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _add_rotate_flags(p: argparse.ArgumentParser) -> None:
|
|
316
|
+
p.add_argument(
|
|
317
|
+
"--no-fetch", action="store_true", help="work from what is already here"
|
|
318
|
+
)
|
|
319
|
+
p.add_argument("--json", action="store_true", help="the result as data")
|
|
320
|
+
p.add_argument("-q", "--quiet", action="store_true", help="say nothing on success")
|
|
321
|
+
p.add_argument(
|
|
322
|
+
"-v", "--verbose", action="store_true", help="print every git command"
|
|
323
|
+
)
|
|
324
|
+
p.add_argument(
|
|
325
|
+
"--explain", action="store_true", help="print every git command and exit"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def run_rotate(args: argparse.Namespace) -> int:
|
|
330
|
+
options.verbose = args.verbose
|
|
331
|
+
if args.explain:
|
|
332
|
+
return _explain()
|
|
333
|
+
|
|
334
|
+
warn = None if args.quiet else _err
|
|
335
|
+
if args.no_fetch and not args.quiet:
|
|
336
|
+
_err("working from what is already here; refs may be stale (--no-fetch)")
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
result = rotate_mod.rotate(fetch=not args.no_fetch, warn=warn)
|
|
340
|
+
except new_branch.Refusal as exc:
|
|
341
|
+
_err(str(exc))
|
|
342
|
+
return 1
|
|
343
|
+
|
|
344
|
+
if isinstance(result, rotate_mod.CaughtUp):
|
|
345
|
+
if args.json:
|
|
346
|
+
print(json.dumps({"branch": result.branch, "at": result.at}, indent=2))
|
|
347
|
+
elif not args.quiet:
|
|
348
|
+
print(f"{result.branch} is at {result.at}")
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
if args.json:
|
|
352
|
+
print(
|
|
353
|
+
json.dumps(
|
|
354
|
+
{
|
|
355
|
+
"branch": result.branch,
|
|
356
|
+
"stem": result.stem,
|
|
357
|
+
"base": result.base,
|
|
358
|
+
"sha": result.sha,
|
|
359
|
+
},
|
|
360
|
+
indent=2,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
elif not args.quiet:
|
|
364
|
+
print(f"{result.branch} from {R.ref_name(result.base)} at {result.sha}")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --------------------------------------------------------------------------
|
|
369
|
+
# the four that land you somewhere
|
|
370
|
+
# --------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _add_cd_flags(p: argparse.ArgumentParser) -> None:
|
|
374
|
+
"""Shared by every command whose answer is a directory."""
|
|
375
|
+
p.add_argument("--json", action="store_true", help="the result as data")
|
|
376
|
+
p.add_argument("-q", "--quiet", action="store_true", help="the path alone")
|
|
377
|
+
p.add_argument(
|
|
378
|
+
"-v", "--verbose", action="store_true", help="print every git command"
|
|
379
|
+
)
|
|
380
|
+
p.add_argument(
|
|
381
|
+
"--explain", action="store_true", help="print every git command and exit"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _add_add_flags(p: argparse.ArgumentParser) -> None:
|
|
386
|
+
p.add_argument("name", nargs="?", default="", metavar="NAME", help="the branch")
|
|
387
|
+
p.add_argument(
|
|
388
|
+
"base", nargs="?", default="", metavar="BASE", help="what to branch from"
|
|
389
|
+
)
|
|
390
|
+
p.add_argument(
|
|
391
|
+
"--no-fetch", action="store_true", help="branch from what is already here"
|
|
392
|
+
)
|
|
393
|
+
_add_cd_flags(p)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _add_list_flags(p: argparse.ArgumentParser) -> None:
|
|
397
|
+
p.add_argument(
|
|
398
|
+
"query", nargs="?", default="", metavar="QUERY", help="narrow the list"
|
|
399
|
+
)
|
|
400
|
+
p.add_argument(
|
|
401
|
+
"-l", "--list", action="store_true", help="print them all and pick none"
|
|
402
|
+
)
|
|
403
|
+
_add_cd_flags(p)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _add_move_flags(p: argparse.ArgumentParser) -> None:
|
|
407
|
+
p.add_argument(
|
|
408
|
+
"name", nargs="?", default="", metavar="NEW", help="the new branch name"
|
|
409
|
+
)
|
|
410
|
+
_add_cd_flags(p)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _add_remove_flags(p: argparse.ArgumentParser) -> None:
|
|
414
|
+
p.add_argument(
|
|
415
|
+
"query", nargs="?", default="", metavar="PATH|QUERY", help="which one"
|
|
416
|
+
)
|
|
417
|
+
p.add_argument(
|
|
418
|
+
"-f", "--force", action="store_true", help="remove it even when unfinished"
|
|
419
|
+
)
|
|
420
|
+
p.add_argument(
|
|
421
|
+
"--delete-ignored",
|
|
422
|
+
action="store_true",
|
|
423
|
+
help="also delete its gitignored files; nothing restores them",
|
|
424
|
+
)
|
|
425
|
+
p.add_argument("--no-fetch", action="store_true", help="use the refs already here")
|
|
426
|
+
p.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
427
|
+
_add_cd_flags(p)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _landed(args: argparse.Namespace, landed: wt_mod.Landed) -> int:
|
|
431
|
+
"""One destination on stdout, so `cd $(gwa x)` works."""
|
|
432
|
+
if args.json:
|
|
433
|
+
print(
|
|
434
|
+
json.dumps(
|
|
435
|
+
{
|
|
436
|
+
"path": landed.path,
|
|
437
|
+
"branch": landed.branch,
|
|
438
|
+
"created": landed.created,
|
|
439
|
+
},
|
|
440
|
+
indent=2,
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
return 0
|
|
444
|
+
print(landed.path)
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def run_add(args: argparse.Namespace) -> int:
|
|
449
|
+
options.verbose = args.verbose
|
|
450
|
+
if args.explain:
|
|
451
|
+
return _explain()
|
|
452
|
+
if not args.name:
|
|
453
|
+
_err(f"usage: {args.prog} NAME [BASE]")
|
|
454
|
+
return 2
|
|
455
|
+
|
|
456
|
+
warn = None if args.quiet else _err
|
|
457
|
+
try:
|
|
458
|
+
landed = wt_mod.add(args.name, args.base, fetch=not args.no_fetch, warn=warn)
|
|
459
|
+
except new_branch.Refusal as exc:
|
|
460
|
+
_err(str(exc))
|
|
461
|
+
return 1
|
|
462
|
+
if not args.quiet and not args.json:
|
|
463
|
+
made = "on a new branch" if landed.created else "on the branch already here"
|
|
464
|
+
_err(f"{landed.branch} {made}")
|
|
465
|
+
return _landed(args, landed)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def run_list(args: argparse.Namespace) -> int:
|
|
469
|
+
options.verbose = args.verbose
|
|
470
|
+
if args.explain:
|
|
471
|
+
return _explain()
|
|
472
|
+
|
|
473
|
+
main = R.main_worktree()
|
|
474
|
+
rows = [w for w in R.worktrees() if "bare" not in w.flags and w.path != main]
|
|
475
|
+
found = pick.matches(args.query, rows)
|
|
476
|
+
|
|
477
|
+
if args.json:
|
|
478
|
+
print(
|
|
479
|
+
json.dumps(
|
|
480
|
+
[
|
|
481
|
+
{"branch": w.branch, "path": w.path, "flags": sorted(w.flags)}
|
|
482
|
+
for w in found
|
|
483
|
+
],
|
|
484
|
+
indent=2,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
if not found:
|
|
490
|
+
_err(
|
|
491
|
+
"no worktree matches"
|
|
492
|
+
if args.query
|
|
493
|
+
else "no worktrees besides the main checkout"
|
|
494
|
+
)
|
|
495
|
+
return 1
|
|
496
|
+
|
|
497
|
+
if args.list:
|
|
498
|
+
width = max(len(w.label) for w in found)
|
|
499
|
+
for w in found:
|
|
500
|
+
print(f"{w.label:<{width}} {_pretty(w.path)}")
|
|
501
|
+
return 0
|
|
502
|
+
|
|
503
|
+
chosen = pick.choose(found, _err)
|
|
504
|
+
if chosen is None:
|
|
505
|
+
_err("nothing picked")
|
|
506
|
+
return 0
|
|
507
|
+
print(chosen.path)
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def run_move(args: argparse.Namespace) -> int:
|
|
512
|
+
options.verbose = args.verbose
|
|
513
|
+
if args.explain:
|
|
514
|
+
return _explain()
|
|
515
|
+
if not args.name:
|
|
516
|
+
_err(f"usage: {args.prog} NEW")
|
|
517
|
+
return 2
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
landed = wt_mod.move(args.name)
|
|
521
|
+
except new_branch.Refusal as exc:
|
|
522
|
+
_err(str(exc))
|
|
523
|
+
return 1
|
|
524
|
+
if not args.quiet and not args.json:
|
|
525
|
+
_err(f"renamed to {landed.branch}")
|
|
526
|
+
return _landed(args, landed)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def run_remove(args: argparse.Namespace) -> int:
|
|
530
|
+
options.verbose = args.verbose
|
|
531
|
+
if args.explain:
|
|
532
|
+
return _explain()
|
|
533
|
+
|
|
534
|
+
rows, _, _ = _assess(args, judge=wt_mod.removable)
|
|
535
|
+
found = pick.matches(
|
|
536
|
+
args.query, [R.Worktree(v.path, "", v.branch, frozenset()) for v in rows]
|
|
537
|
+
)
|
|
538
|
+
by_path = {v.path: v for v in rows}
|
|
539
|
+
candidates = [by_path[w.path] for w in found if w.path in by_path]
|
|
540
|
+
|
|
541
|
+
if not candidates:
|
|
542
|
+
_err(
|
|
543
|
+
"no worktree matches"
|
|
544
|
+
if args.query
|
|
545
|
+
else "no worktrees besides the main checkout"
|
|
546
|
+
)
|
|
547
|
+
return 1
|
|
548
|
+
|
|
549
|
+
picked = pick.choose(
|
|
550
|
+
[R.Worktree(v.path, "", v.branch, frozenset()) for v in candidates], _err
|
|
551
|
+
)
|
|
552
|
+
if picked is None:
|
|
553
|
+
_err("nothing picked")
|
|
554
|
+
return 0
|
|
555
|
+
chosen = by_path[picked.path]
|
|
556
|
+
|
|
557
|
+
if chosen.verdict != prune.REMOVE and not args.force:
|
|
558
|
+
_err(f"{chosen.label} is not finished: {chosen.why}")
|
|
559
|
+
_err("pass --force to remove the worktree anyway; the branch is kept")
|
|
560
|
+
return 1
|
|
561
|
+
|
|
562
|
+
prune.plan([chosen], args.delete_ignored, _err)
|
|
563
|
+
if not args.yes:
|
|
564
|
+
try:
|
|
565
|
+
if not prune.confirm(1):
|
|
566
|
+
_err("nothing removed")
|
|
567
|
+
return 0
|
|
568
|
+
except Refused as exc:
|
|
569
|
+
_err(str(exc))
|
|
570
|
+
return 2
|
|
571
|
+
|
|
572
|
+
keep_branch = chosen.verdict != prune.REMOVE
|
|
573
|
+
# --force keeps the branch: the worktree was in the way, the work was not.
|
|
574
|
+
destination = wt_mod.remove(
|
|
575
|
+
prune.Verdict(chosen.verdict, "", chosen.path, chosen.why)
|
|
576
|
+
if keep_branch
|
|
577
|
+
else chosen,
|
|
578
|
+
_err,
|
|
579
|
+
)
|
|
580
|
+
if not args.quiet and not args.json:
|
|
581
|
+
_err(f"removed {chosen.label}")
|
|
582
|
+
if args.json:
|
|
583
|
+
print(json.dumps({"removed": chosen.label, "path": destination}, indent=2))
|
|
584
|
+
return 0
|
|
585
|
+
if destination:
|
|
586
|
+
print(destination)
|
|
587
|
+
return 0
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# --------------------------------------------------------------------------
|
|
591
|
+
# entry points
|
|
592
|
+
# --------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _add_prune_flags(p: argparse.ArgumentParser) -> None:
|
|
596
|
+
_add_assess_flags(p)
|
|
597
|
+
p.add_argument(
|
|
598
|
+
"-y", "--yes", action="store_true", help="do not ask before removing"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
_COMMANDS = {
|
|
603
|
+
"status": (run_status, _add_assess_flags, "say which worktrees are finished"),
|
|
604
|
+
"prune": (run_prune, _add_prune_flags, "remove the ones status marks removable"),
|
|
605
|
+
"new-branch": (
|
|
606
|
+
run_new_branch,
|
|
607
|
+
_add_new_branch_flags,
|
|
608
|
+
"start a branch off the head branch",
|
|
609
|
+
),
|
|
610
|
+
"rotate": (run_rotate, _add_rotate_flags, "start the next branch after this one"),
|
|
611
|
+
"add": (run_add, _add_add_flags, "create a worktree and land you in it"),
|
|
612
|
+
"list": (run_list, _add_list_flags, "pick one of this repository's worktrees"),
|
|
613
|
+
"move": (run_move, _add_move_flags, "rename this worktree's branch and move it"),
|
|
614
|
+
"remove": (
|
|
615
|
+
run_remove,
|
|
616
|
+
_add_remove_flags,
|
|
617
|
+
"remove a worktree whose branch is finished",
|
|
618
|
+
),
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
_STATUS_HELP = "Say which of this repository's worktrees are finished, and why."
|
|
622
|
+
_PRUNE_HELP = "Remove the worktrees gws marks removable, and their branches."
|
|
623
|
+
_NEW_BRANCH_HELP = "Fetch, then branch NAME off the head branch and check it out."
|
|
624
|
+
_ROTATE_HELP = (
|
|
625
|
+
"Start the next branch after this one, named <stem>-YYYY-MM-DD_NNN. On the "
|
|
626
|
+
"head branch there is no chain to continue, so it catches that up to the "
|
|
627
|
+
"remote instead."
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _parser(prog: str, description: str) -> argparse.ArgumentParser:
|
|
632
|
+
p = argparse.ArgumentParser(prog=prog, description=description)
|
|
633
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
634
|
+
return p
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def gws(argv: list[str] | None = None) -> int:
|
|
638
|
+
"""Read-only. There is no flag here that removes anything."""
|
|
639
|
+
p = _parser("gws", _STATUS_HELP)
|
|
640
|
+
_add_assess_flags(p)
|
|
641
|
+
return _dispatch(p, argv, run_status)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def gwp(argv: list[str] | None = None) -> int:
|
|
645
|
+
"""Asks before it acts, unless --yes."""
|
|
646
|
+
p = _parser("gwp", _PRUNE_HELP)
|
|
647
|
+
_add_prune_flags(p)
|
|
648
|
+
return _dispatch(p, argv, run_prune)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def gwnb(argv: list[str] | None = None) -> int:
|
|
652
|
+
"""Checking out needs no shell either."""
|
|
653
|
+
p = _parser("gwnb", _NEW_BRANCH_HELP)
|
|
654
|
+
_add_new_branch_flags(p)
|
|
655
|
+
return _dispatch(p, argv, run_new_branch)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def gwa(argv: list[str] | None = None) -> int:
|
|
659
|
+
"""Prints the destination. The shell function does the cd."""
|
|
660
|
+
p = _parser("gwa", "Create a worktree for NAME and print where it is.")
|
|
661
|
+
_add_add_flags(p)
|
|
662
|
+
return _dispatch(p, argv, run_add)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def gwl(argv: list[str] | None = None) -> int:
|
|
666
|
+
"""The same, for one that already exists."""
|
|
667
|
+
p = _parser("gwl", "Pick one of this repository's worktrees and print its path.")
|
|
668
|
+
_add_list_flags(p)
|
|
669
|
+
return _dispatch(p, argv, run_list)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def gwm(argv: list[str] | None = None) -> int:
|
|
673
|
+
"""Renaming the directory you stand in is why this one is not optional."""
|
|
674
|
+
p = _parser("gwm", "Rename this worktree's branch to NEW and move it to match.")
|
|
675
|
+
_add_move_flags(p)
|
|
676
|
+
return _dispatch(p, argv, run_move)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def gwr(argv: list[str] | None = None) -> int:
|
|
680
|
+
"""Prints a path only when the caller was standing in what went."""
|
|
681
|
+
p = _parser("gwr", "Remove a worktree whose branch is finished, and the branch.")
|
|
682
|
+
_add_remove_flags(p)
|
|
683
|
+
return _dispatch(p, argv, run_remove)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def gwrot(argv: list[str] | None = None) -> int:
|
|
687
|
+
"""Naming a branch and continuing a series are two commands."""
|
|
688
|
+
p = _parser("gwrot", _ROTATE_HELP)
|
|
689
|
+
_add_rotate_flags(p)
|
|
690
|
+
return _dispatch(p, argv, run_rotate)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def main(argv: list[str] | None = None) -> int:
|
|
694
|
+
"""The CLI a shim and an agent call."""
|
|
695
|
+
p = _parser("worktrees", "Git worktree commands that refuse to lose work.")
|
|
696
|
+
subs = p.add_subparsers(dest="command")
|
|
697
|
+
for name, (_, flags, help_text) in _COMMANDS.items():
|
|
698
|
+
flags(subs.add_parser(name, help=help_text))
|
|
699
|
+
args_in = sys.argv[1:] if argv is None else argv
|
|
700
|
+
if (
|
|
701
|
+
args_in
|
|
702
|
+
and args_in[0].startswith("-")
|
|
703
|
+
and args_in[0]
|
|
704
|
+
not in (
|
|
705
|
+
"-h",
|
|
706
|
+
"--help",
|
|
707
|
+
"--version",
|
|
708
|
+
)
|
|
709
|
+
):
|
|
710
|
+
# `worktrees --explain` is not about one command; status answers it.
|
|
711
|
+
args_in = ["status", *args_in]
|
|
712
|
+
return _dispatch(p, args_in, None)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _dispatch(
|
|
716
|
+
p: argparse.ArgumentParser,
|
|
717
|
+
argv: list[str] | None,
|
|
718
|
+
run: Callable[[argparse.Namespace], int] | None,
|
|
719
|
+
) -> int:
|
|
720
|
+
raw = sys.argv[1:] if argv is None else argv
|
|
721
|
+
# Before parsing, so it is refused rather than absorbed. A flag meaning
|
|
722
|
+
# "do not act" on a command that already asks is a no-op wearing the
|
|
723
|
+
# clothes of a safety feature, and somebody will one day read it as the
|
|
724
|
+
# reason a sweep was safe.
|
|
725
|
+
if "--dry-run" in raw:
|
|
726
|
+
_err("there is no --dry-run: gws reports, and gwp asks before removing")
|
|
727
|
+
return 2
|
|
728
|
+
args = p.parse_args(raw)
|
|
729
|
+
# argparse gives a subparser its own prog; rebuild it rather than reach
|
|
730
|
+
# into the private table for it.
|
|
731
|
+
command = getattr(args, "command", None)
|
|
732
|
+
args.prog = f"{p.prog} {command}" if command else p.prog
|
|
733
|
+
if run is None:
|
|
734
|
+
if args.command is None:
|
|
735
|
+
p.print_help()
|
|
736
|
+
return 0
|
|
737
|
+
run = _COMMANDS[args.command][0]
|
|
738
|
+
try:
|
|
739
|
+
return run(args)
|
|
740
|
+
except _Stop as exc:
|
|
741
|
+
_err(str(exc))
|
|
742
|
+
return exc.code
|
|
743
|
+
except Refused as exc:
|
|
744
|
+
_err(str(exc))
|
|
745
|
+
return 3
|
|
746
|
+
except GitError as exc:
|
|
747
|
+
_err(str(exc))
|
|
748
|
+
return 1
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
if __name__ == "__main__":
|
|
752
|
+
raise SystemExit(main())
|