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.
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())