worktree-aid 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.
@@ -0,0 +1,305 @@
1
+ Metadata-Version: 2.4
2
+ Name: worktree-aid
3
+ Version: 1.0
4
+ Summary: Linux command line tool to aid creating, switching, and removing git worktrees
5
+ Author-email: Mark Blakeney <mark.blakeney@bullet-systems.net>
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/bulletmark/worktree-aid
8
+ Keywords: git,worktree,fzf
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: coolname
12
+
13
+ # worktree-aid
14
+ [![PyPi](https://img.shields.io/pypi/v/worktree-aid)](https://pypi.org/project/worktree-aid/)
15
+ [![AUR](https://img.shields.io/aur/version/worktree-aid)](https://aur.archlinux.org/packages/worktree-aid/)
16
+
17
+ This is a Linux command line tool to conveniently add, remove, and change
18
+ directories for [git worktrees][gitw]. A [fuzzy finder][fzf] is used to show
19
+ current worktrees and prompt user for the name if not given.
20
+
21
+ After following the instructions in the [Installation](#installation-or-upgrade)
22
+ and [Setup](#setup) sections below, an `wt` shell command/alias is available to
23
+ use to manage git worktrees. There are 3 commands typically used:
24
+
25
+ - `wt add` (or `wt a`) to add a new worktree + branch and automatically cd to
26
+ it. If you don't specify a worktree name, a new unique name will be
27
+ automatically created for you.
28
+
29
+ - `wt cd` (or `wt c`) to change directory to a specified worktree. You can use
30
+ `/` as a shortcut to the toplevel repository directory. If you don't specify a
31
+ worktree name, then a [fuzzy finder][fzf] will prompt you with a list of
32
+ worktrees to select from and be cd'd to.
33
+
34
+ - `wt rm` (or `wt r`) to remove a worktree + branch. If you don't specify a
35
+ worktree name, then a [fuzzy finder][fzf] will prompt you with a list of
36
+ worktrees to select from. The current worktree is first in the list and is the
37
+ default selection to remove. If you delete the current worktree then you will
38
+ be automatically cd'd to the toplevel repository directory.
39
+
40
+ There are some other options and commands available, as described in later
41
+ sections. Type `wt` to see an overall help/usage summary, or `wt <command> -h`
42
+ to see specific help/usage for any individual command.
43
+
44
+ The project homepage and latest documentation is at
45
+ https://github.com/bulletmark/worktree-aid.
46
+
47
+ ## Usage
48
+
49
+ Type `wt` or `wt -h` to view the usage summary:
50
+
51
+ ```
52
+ usage: wt [-B BASEDIR] [-R] [-U] [-F FUZZY] [-h] [-v]
53
+ {add,a,rm,r,cd,c,ls,l,init} ...
54
+
55
+ Linux command line tool to conveniently add, remove, and change directories
56
+ for git worktrees. Prompts user to select worktree using fuzzy finder if no
57
+ worktree name is given.
58
+
59
+ options:
60
+ -B, --basedir BASEDIR
61
+ base directory for newly added worktrees,
62
+ default="../worktrees/{repo}".
63
+ -R, --relative display worktree paths relative instead of absolute
64
+ -U, --no-user do not substitute "~" for home directory
65
+ -F, --fuzzy FUZZY fuzzy finder program, default="fzf"
66
+ -h, --help show help message and exit
67
+ -v, --version show program version and exit
68
+
69
+ Commands:
70
+ {add,a,rm,r,cd,c,ls,l,init}
71
+ add (a) Add new worktree + branch.
72
+ rm (r) Remove worktree + branch.
73
+ cd (c) Change directory to specified worktree.
74
+ ls (l) List current worktrees.
75
+ init Output shell initialization code.
76
+ ```
77
+
78
+ Type `wt <command> -h` to see specific help/usage for any
79
+ individual command:
80
+
81
+ ### Command `add`
82
+
83
+ ```
84
+ usage: wt add [-h] [-d] [worktree]
85
+
86
+ Add new worktree + branch.
87
+
88
+ positional arguments:
89
+ worktree new worktree + branch to add. A name is automatically created
90
+ if not specified.
91
+
92
+ options:
93
+ -h, --help show help message and exit
94
+ -d, --detach add detached worktree only, i.e. without adding a new branch
95
+
96
+ aliases: a
97
+ ```
98
+
99
+ ### Command `rm`
100
+
101
+ ```
102
+ usage: wt rm [-h] [-k] [-f] [worktree]
103
+
104
+ Remove worktree + branch.
105
+
106
+ positional arguments:
107
+ worktree worktree + branch name to remove. If not specified then
108
+ fuzzy finder will prompt with a list of worktrees.
109
+
110
+ options:
111
+ -h, --help show help message and exit
112
+ -k, --keep-branch remove worktree but keep branch
113
+ -f, --force force removal of worktree + branch even if untracked or
114
+ unmerged changes exist.
115
+
116
+ aliases: r
117
+ ```
118
+
119
+ ### Command `cd`
120
+
121
+ ```
122
+ usage: wt cd [-h] [worktree]
123
+
124
+ Change directory to specified worktree.
125
+
126
+ positional arguments:
127
+ worktree Worktree name to change directory to. If not specified then
128
+ fuzzy finder will prompt with a list of worktrees.
129
+
130
+ options:
131
+ -h, --help show help message and exit
132
+
133
+ aliases: c
134
+ ```
135
+
136
+ ### Command `ls`
137
+
138
+ ```
139
+ usage: wt ls [-h]
140
+
141
+ List current worktrees.
142
+
143
+ options:
144
+ -h, --help show help message and exit
145
+
146
+ aliases: l
147
+ ```
148
+
149
+ ### Command `init`
150
+
151
+ ```
152
+ usage: wt init [-h] [command]
153
+
154
+ Output shell initialization code. Must be invoked using `source <(worktree-
155
+ aid)` in your shell `~/.bashrc` or `~/.zshrc` initialization file to create
156
+ the shell alias/function by which you invoke this program.
157
+
158
+ positional arguments:
159
+ command alternative command name, and optional default arguments,
160
+ default="wt"
161
+
162
+ options:
163
+ -h, --help show help message and exit
164
+ ```
165
+
166
+ ## Installation or Upgrade
167
+
168
+ Python 3.10 or later is required. Install using [`uv tool`][uvtool]:
169
+
170
+ ```sh
171
+ $ uv tool install worktree-aid
172
+
173
+ # To upgrade:
174
+ $ uv tool upgrade worktree-aid
175
+
176
+ # To uninstall:
177
+ $ uv tool uninstall worktree-aid
178
+ ```
179
+
180
+ Or, on Arch Linux:
181
+
182
+ ```sh
183
+ $ yay -S worktree-aid # or your preferred AUR helper
184
+ ```
185
+
186
+ You also need to install a fuzzy finder program such as [`fzf`][fzf] which is
187
+ the default used by `worktree-aid`. See [fuzzy finder installation
188
+ instructions](fuzzy-finder-installation) for possible alternatives.
189
+
190
+ ## Setup
191
+
192
+ A user who wants to use `worktree-aid` must add the following line to their
193
+ `~/.bashrc` (`bash` user) or `~/.zshrc` (`zsh` user). Ensure it is added
194
+ after where your PATH is set up so that the command `worktree-aid` can be found. This
195
+ creates the `wt` wrapper command in your interactive shell session as a tiny
196
+ function.
197
+
198
+ ```sh
199
+ source <(worktree-aid init)
200
+ ```
201
+
202
+ Then log out and back in again to be able to use the new `wt` function in your
203
+ shell.
204
+
205
+ ## Alternative Command Name
206
+
207
+ You can use an alternative command name instead of the default `wt` if you
208
+ prefer. To do this, simply append your desired command name as the first
209
+ argument to the `worktree-aid init` option in your shell initialization code.
210
+
211
+ E.g, to use the command name `wx` rather than the default `wt`, use the
212
+ following in your `~/.bashrc` or `~/.zshrc` file:
213
+
214
+ ```sh
215
+ source <(worktree-aid init wx)
216
+ ```
217
+
218
+ Then log out/in, and then use `wx` command instead of the default `wt`.
219
+
220
+ ## Default Options
221
+
222
+ You can also set default `worktree-aid` options by appending options in the shell
223
+ initialization code, e.g:
224
+
225
+ ```sh
226
+ source <(worktree-aid init "wt -R")
227
+ ```
228
+
229
+ The above sets `-R` (for relative display of worktree directories) as default for your
230
+ `wt` command.
231
+
232
+ The following options are sensible candidates to set as default options:
233
+
234
+ - `-B/--basedir`,
235
+ - `-R/--relative`,
236
+ - `-U/--no-user`,
237
+ - `-F/--fuzzy`.
238
+
239
+ ## Worktree Base Directory
240
+
241
+ The `-B/--basedir` option allows you to specify a base directory for newly added
242
+ worktrees. It is set to a default as below but you can change this to any
243
+ directory you like. It can be absolute or relative where relative paths are
244
+ relative to base repository directory.
245
+
246
+ - Default is `-B ../worktrees/{repo}` which is the same as the [Zed] editor uses.
247
+ - E.g. can use `-B ../{repo}.worktrees` which is same as [VS Code] uses.
248
+ - E.g. can use `-B ~/worktrees/{repo}` to put all worktrees in a subdirectory of your home directory.
249
+
250
+ The following place-markers can be used in the definition of the base directory:
251
+
252
+ - `{repo}`: Substituted with the base name of the repository.
253
+ - `{user}`: Substituted with the name of the user.
254
+ - `{home}`: Substituted with the home directory of the user (also can use `~` at start of a path).
255
+
256
+ Most likely if you will want to set a custom basedir then you will set `-B` as a
257
+ [default option](#default-options).
258
+
259
+ ## Display as Relative Worktree Directories
260
+
261
+ The `git worktree list` command displays absolute directory paths, and
262
+ `worktree-aid` does also by default, but many users prefer them displayed as
263
+ shorter relative paths. The Git worktree command does not provide this but you
264
+ can enable it in `worktree-aid` by adding the `-R/--relative` option, e.g:
265
+
266
+ ```sh
267
+ $ wt l
268
+ ../development 9796714 [development]
269
+ ../milestone1 bc921b8 [milestone1]
270
+ ../test e6d965a [test]
271
+ . f76b8e0 [main]
272
+ ```
273
+
274
+ Most likely you will want to set `-R` as a [default option](#default-options).
275
+
276
+ ## Fuzzy Finder Integration
277
+
278
+ [`fzf`][fzf] is the default fuzzy finder used by `worktree-aid`, but you can use
279
+ any of the popular other command line fuzzy search finders such as [`sk`][skim],
280
+ [`tv`][television], or [`fzy`][fzy].
281
+
282
+ E.g. to use [`sk`][skim], put this in your `~/.bashrc` or `~/.zshrc` file:
283
+
284
+ ```sh
285
+ source <(worktree-aid init "wt -F sk")
286
+ ```
287
+
288
+ You can also get fancy and add preview options etc to your fuzzy finder command line.
289
+ Most likely you will want to set `-F` as a [default option](#default-options).
290
+
291
+ ## License
292
+
293
+ GPL-3.0-or-later.
294
+
295
+ [gitw]: https://git-scm.com/docs/git-worktree
296
+ [worktree-aid]: https://github.com/bulletmark/worktree-aid
297
+ [PyPI]: https://pypi.org/project/worktree-aid
298
+ [AUR]: https://aur.archlinux.org/packages/worktree-aid
299
+ [uvtool]: https://docs.astral.sh/uv/guides/tools/#installing-tools
300
+ [fzf]: https://github.com/junegunn/fzf
301
+ [fzy]: https://github.com/jhawthorn/fzy
302
+ [skim]: https://github.com/skim-rs/skim
303
+ [television]: https://github.com/alexpasmantier/television
304
+ [Zed]: https://zed.dev/
305
+ [VS Code]: https://code.visualstudio.com/
@@ -0,0 +1,6 @@
1
+ worktree_aid.py,sha256=Y_6lnIcZQwII2X6z-EinbwQf9R1IGt5OwoS1QNOmtfY,15817
2
+ worktree_aid-1.0.dist-info/METADATA,sha256=hPx3KSBViUYZJDy9HeQJiabM-m-aJ7TRu387mq9k7jo,9742
3
+ worktree_aid-1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
4
+ worktree_aid-1.0.dist-info/entry_points.txt,sha256=KWqgwMgud29RLqut8zQc0Qt8V0GOo3FgIMEaoMRZzkg,51
5
+ worktree_aid-1.0.dist-info/top_level.txt,sha256=Gapkg-MU1S0CSdMt3sfRe7jKB6cAJswkQSupI9qNymg,13
6
+ worktree_aid-1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ worktree-aid = worktree_aid:main
@@ -0,0 +1 @@
1
+ worktree_aid
worktree_aid.py ADDED
@@ -0,0 +1,567 @@
1
+ """
2
+ Linux command line tool to conveniently add, remove, and change directories for
3
+ git worktrees. Prompts user to select worktree using fuzzy finder if no worktree
4
+ name is given.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import getpass
10
+ import os
11
+ import shlex
12
+ import subprocess
13
+ import sys
14
+ from argparse import SUPPRESS, ArgumentParser, Namespace
15
+ from collections.abc import Callable, Sequence
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ PROG = Path(__file__).stem.replace('_', '-')
21
+ HOME = Path.home()
22
+ HASH_LEN = 7
23
+
24
+ # Default command name (shell function) this program is installed as. Can be
25
+ # changed using command line option.
26
+ DEFCMD = 'wt'
27
+
28
+ # Default fuzzy finder. Can be changed using command line option.
29
+ DEFAULT_FUZZY = 'fzf'
30
+
31
+ # Relative path from top level repo dir to directory for newly created
32
+ # worktrees. Can be changed using command line option. Can use {user}, {repo},
33
+ # and {home} as placeholders for the current user name, repo name, and home
34
+ # directory.
35
+ BASEDIR = '../worktrees/{repo}'
36
+
37
+ # Set of worktree names that are shortcuts to the top-level worktree.
38
+ TOPLEVEL_SHORTCUTS = {'/'}
39
+
40
+ # Template for the shell code injected into user's shell session
41
+ SHELLCODE = """
42
+ !cmd() {
43
+ local d
44
+ d=$(!prog -_ "$@")
45
+ local r=$?
46
+
47
+ if [ $r -ne 0 ]; then
48
+ if [ $r -eq 2 ]; then
49
+ return 0
50
+ fi
51
+ return $r
52
+ fi
53
+
54
+ cd -- "$d"
55
+ }
56
+ """
57
+
58
+
59
+ def init_code(cmd: str) -> str:
60
+ "Return shell init code as string"
61
+ from string import Template
62
+
63
+ # We need to change the template delimiter because the standard
64
+ # delimiter "$" is too common in regular shell code .
65
+ class CTemplate(Template):
66
+ delimiter = '!'
67
+
68
+ prog = sys.argv[0]
69
+ arglist = cmd.split(maxsplit=1)
70
+ if len(arglist) > 1:
71
+ cmd, opts = arglist
72
+ prog += f' {opts}'
73
+
74
+ return CTemplate(SHELLCODE.strip()).substitute(cmd=cmd, prog=prog)
75
+
76
+
77
+ def run(
78
+ cmd: Sequence[str],
79
+ *,
80
+ stdin: str | None = None,
81
+ capture: bool = True,
82
+ ignore_error: bool = False,
83
+ ) -> str:
84
+ "Run command and return stdout"
85
+ stdout = subprocess.PIPE if capture else open('/dev/tty', 'w')
86
+ try:
87
+ res = subprocess.run(cmd, stdout=stdout, text=capture, input=stdin)
88
+ except Exception as e:
89
+ sys.exit(f'error: failed to run command "{cmd[0]}": {e}')
90
+
91
+ if not ignore_error and res.returncode != 0:
92
+ sys.exit(res.returncode)
93
+
94
+ if capture:
95
+ return res.stdout.strip()
96
+
97
+ stdout.close() # type: ignore
98
+ return ''
99
+
100
+
101
+ def get_title(desc: str) -> str:
102
+ "Return single title line from command description"
103
+ res = []
104
+ for line in desc.splitlines():
105
+ line = line.strip()
106
+ res.append(line)
107
+ if line.endswith('.'):
108
+ return ' '.join(res)
109
+
110
+ sys.exit('Must end description with a full stop.')
111
+
112
+
113
+ def unexpanduser(path: Path) -> Path:
114
+ "Return path name, with $HOME replaced by ~ (opposite of Path.expanduser())"
115
+ if path.parts[: len(HOME.parts)] != HOME.parts:
116
+ return path
117
+
118
+ return Path('~', *path.parts[len(HOME.parts) :])
119
+
120
+
121
+ def generate_new_name(exists: Callable[[str], Any]) -> str:
122
+ "Generate a new worktree name"
123
+ from coolname import generate_slug
124
+
125
+ for _ in range(100):
126
+ if not exists(name := generate_slug(2)):
127
+ return name
128
+
129
+ sys.exit('error: failed to generate a new worktree name.')
130
+
131
+
132
+ def print_version(args: Namespace) -> int:
133
+ "Print program version"
134
+ from importlib import metadata
135
+
136
+ try:
137
+ version = metadata.version(PROG)
138
+ except Exception:
139
+ version = '?'
140
+
141
+ if args._:
142
+ with open('/dev/tty', 'w') as tty:
143
+ print(version, file=tty)
144
+
145
+ return 2
146
+
147
+ print(version)
148
+ return 0
149
+
150
+
151
+ def print_help(args: Namespace) -> int:
152
+ "Print program help message"
153
+ if 'parser' in args:
154
+ args.parser.print_help(sys.stderr)
155
+ else:
156
+ args._opt.print_help(sys.stderr)
157
+
158
+ return 2 if args._ else 0
159
+
160
+
161
+ @dataclass
162
+ class Tree:
163
+ "Data for an individual worktree"
164
+
165
+ path: Path
166
+ path_display: str
167
+ head: str = ''
168
+ branch: str = ''
169
+
170
+
171
+ class Trees:
172
+ "Class to manage the collection of worktrees"
173
+
174
+ trees: list[Tree]
175
+ toplevel: Tree
176
+ current: Tree
177
+
178
+ @classmethod
179
+ def fetch(cls, args: Namespace) -> None:
180
+ "Get worktrees"
181
+ trees = []
182
+ tree = None
183
+ cwdparts = Path.cwd().parts
184
+ phere = pindex = -1
185
+ for line in run('git worktree list --porcelain'.split()).splitlines():
186
+ if not (line := line.strip()):
187
+ continue
188
+
189
+ if len(fields := line.split(maxsplit=1)) < 2:
190
+ continue
191
+
192
+ field, value = fields
193
+
194
+ if field == 'worktree':
195
+ path = Path(value)
196
+
197
+ if args.relative:
198
+ path_display = os.path.relpath(path)
199
+ elif args.no_user:
200
+ path_display = str(path)
201
+ else:
202
+ path_display = str(unexpanduser(path))
203
+
204
+ plen = len(path.parts)
205
+ if path.parts == cwdparts[:plen] and plen > phere:
206
+ phere = plen
207
+ pindex = len(trees)
208
+
209
+ trees.append(tree := Tree(path, path_display))
210
+ elif tree:
211
+ if field == 'HEAD':
212
+ tree.head = value[:HASH_LEN]
213
+ elif field == 'branch':
214
+ tree.branch = value.split('/')[-1]
215
+
216
+ if not trees:
217
+ sys.exit('error: no worktrees found.')
218
+
219
+ if pindex > 0:
220
+ trees = [trees[pindex]] + trees[:pindex] + trees[pindex + 1 :]
221
+ cls.toplevel = trees[1]
222
+ else:
223
+ cls.toplevel = trees[0]
224
+
225
+ cls.current = trees[0]
226
+ cls.trees = trees
227
+
228
+ @classmethod
229
+ def get_trees(cls) -> list[str]:
230
+ "Fetch string list of worktrees"
231
+ pw = max(len(str(t.path_display)) for t in cls.trees)
232
+ lines = []
233
+ for t in cls.trees:
234
+ tlist = [f'{t.path_display:{pw}}']
235
+ if t.head:
236
+ tlist.append(t.head)
237
+ if t.branch:
238
+ tlist.append(f'[{t.branch}]')
239
+ else:
240
+ tlist.append('detached')
241
+
242
+ lines.append(' '.join(tlist))
243
+
244
+ return lines
245
+
246
+ @classmethod
247
+ def get_tree(cls, text: str) -> Tree | None:
248
+ "Return first tree where branch, then path name, then head matches given text"
249
+ for tree in cls.trees:
250
+ if tree.branch == text:
251
+ return tree
252
+
253
+ for tree in cls.trees:
254
+ if tree.path.name == text:
255
+ return tree
256
+
257
+ for tree in cls.trees:
258
+ if tree.head == text:
259
+ return tree
260
+
261
+ @classmethod
262
+ def create_worktree(cls, name: str, args: Namespace) -> Path:
263
+ "Create a new worktree and branch with the given name"
264
+ if not name:
265
+ name = generate_new_name(cls.get_tree)
266
+
267
+ try:
268
+ basedir = args.basedir.format(
269
+ user=getpass.getuser(), repo=cls.toplevel.path.name, home=str(HOME)
270
+ )
271
+ except Exception as e:
272
+ sys.exit(f'error: failed to format basedir: {e}')
273
+
274
+ basepath = Path(basedir).expanduser()
275
+ basepath = cls.toplevel.path / basepath
276
+ basepath.mkdir(parents=True, exist_ok=True)
277
+ path = basepath / name
278
+
279
+ cmd = 'git worktree add'.split()
280
+ if args.detach:
281
+ cmd.append('--detach')
282
+ run(cmd + [str(path)], capture=False)
283
+ return path
284
+
285
+ @classmethod
286
+ def remove_worktree(cls, name: str, args: Namespace) -> Path | None:
287
+ "Remove the worktree and branch with the given name"
288
+ if name:
289
+ if not (tree := cls.get_tree(name)):
290
+ sys.exit(f'error: no worktree found with name "{name}".')
291
+ else:
292
+ # If no name is given, remove the current worktree and branch
293
+ tree = cls.current
294
+
295
+ # Change to the top-level worktree directory before deleting a worktree
296
+ # and/or branch, in case the current directory is removed.
297
+ os.chdir(cls.toplevel.path)
298
+
299
+ cmd = 'git worktree remove'.split()
300
+ if args.force:
301
+ cmd.append('--force')
302
+
303
+ run(cmd + [str(tree.path)], capture=False)
304
+
305
+ path_display = os.path.relpath(tree.path) if args.relative else str(tree.path)
306
+ with open('/dev/tty', 'w') as tty:
307
+ print(f'Removed worktree "{path_display}"', file=tty)
308
+
309
+ if tree.branch and not args.keep_branch:
310
+ cmd = 'git branch'.split() + ['-D' if args.force else '-d']
311
+ run(cmd + [tree.branch], capture=False, ignore_error=True)
312
+
313
+ return cls.toplevel.path if tree == cls.current else None
314
+
315
+ @classmethod
316
+ def prompt(cls, args: Namespace) -> Path | None:
317
+ "Prompt user to select a worktree using fuzzy finder"
318
+ wtrees = cls.get_trees()
319
+ if not wtrees:
320
+ sys.exit('error: no worktrees to remove.')
321
+ line = run(shlex.split(args.fuzzy), stdin='\n'.join(wtrees)).strip()
322
+ if not line or line not in wtrees:
323
+ return None
324
+
325
+ return cls.trees[wtrees.index(line)].path
326
+
327
+
328
+ def main() -> int:
329
+ "Main code"
330
+ # Main returns a status code:
331
+ # 0 = Directory written to stdout. Calling script will "cd" to that
332
+ # worktree directory and return error code for that cd command result.
333
+ # 1 = Error/message already written to stderr via sys.exit(). Calling script
334
+ # will silently quit and return that error code.
335
+ # 2 = Caller will silently quit and return exit code 0.
336
+
337
+ # Parse arguments
338
+ opt = ArgumentParser(description=__doc__, add_help=False)
339
+ opt.add_argument(
340
+ '-B',
341
+ '--basedir',
342
+ default=BASEDIR,
343
+ help='base directory for newly added worktrees, default="%(default)s".',
344
+ )
345
+ opt.add_argument(
346
+ '-R',
347
+ '--relative',
348
+ action='store_true',
349
+ help='display worktree paths relative instead of absolute',
350
+ )
351
+ opt.add_argument(
352
+ '-U',
353
+ '--no-user',
354
+ action='store_true',
355
+ help='do not substitute "~" for home directory',
356
+ )
357
+ opt.add_argument(
358
+ '-F',
359
+ '--fuzzy',
360
+ default=DEFAULT_FUZZY,
361
+ help='fuzzy finder program, default="%(default)s"',
362
+ )
363
+ opt.add_argument(
364
+ '-h', '--help', action='store_true', help='show help message and exit'
365
+ )
366
+ opt.add_argument(
367
+ '-v', '--version', action='store_true', help='show program version and exit'
368
+ )
369
+ opt.add_argument('-_', action='store_true', help=SUPPRESS)
370
+ cmd = opt.add_subparsers(title='Commands')
371
+
372
+ # Add each command ..
373
+ for name in globals():
374
+ if not name[0].islower() or not name.endswith('_'):
375
+ continue
376
+
377
+ cls = globals()[name]
378
+ name = name[:-1]
379
+
380
+ if hasattr(cls, 'doc'):
381
+ desc = cls.doc.strip()
382
+ elif cls.__doc__:
383
+ desc = cls.__doc__.strip()
384
+ else:
385
+ sys.exit(f'Must define a docstring for command class "{name}".')
386
+
387
+ title = get_title(desc)
388
+ aliases = cls.aliases if hasattr(cls, 'aliases') else []
389
+ cmdopt = cmd.add_parser(
390
+ name, description=desc, aliases=aliases, help=title, add_help=False
391
+ )
392
+ cmdopt.add_argument(
393
+ '-h', '--help', action='store_true', help='show help message and exit'
394
+ )
395
+
396
+ # Set up this commands own arguments, if it has any
397
+ if hasattr(cls, 'init'):
398
+ cls.init(cmdopt)
399
+
400
+ # Set the function to call
401
+ cmdopt.set_defaults(func=cls.run, parser=cmdopt)
402
+
403
+ args = opt.parse_args()
404
+ args._opt = opt
405
+
406
+ if args.version:
407
+ return print_version(args)
408
+
409
+ if args.help or 'func' not in args:
410
+ return print_help(args)
411
+
412
+ if out := args.func(args):
413
+ print(out)
414
+ return 0
415
+
416
+ return 2 if args._ else 0
417
+
418
+
419
+ # COMMAND
420
+ class add_:
421
+ "Add new worktree + branch."
422
+
423
+ aliases = ('a',)
424
+
425
+ @staticmethod
426
+ def init(parser: ArgumentParser) -> None:
427
+ parser.add_argument(
428
+ '-d',
429
+ '--detach',
430
+ action='store_true',
431
+ help='add detached worktree only, i.e. without adding a new branch',
432
+ )
433
+ parser.add_argument(
434
+ 'worktree',
435
+ default='',
436
+ nargs='?',
437
+ help='new worktree + branch to add. A name is automatically created if not specified.',
438
+ )
439
+
440
+ @staticmethod
441
+ def run(args: Namespace) -> str | None:
442
+ Trees.fetch(args)
443
+ return str(Trees.create_worktree(args.worktree, args))
444
+
445
+
446
+ # COMMAND
447
+ class rm_:
448
+ "Remove worktree + branch."
449
+
450
+ aliases = ('r',)
451
+
452
+ @staticmethod
453
+ def init(parser: ArgumentParser) -> None:
454
+ parser.add_argument(
455
+ '-k',
456
+ '--keep-branch',
457
+ action='store_true',
458
+ help='remove worktree but keep branch',
459
+ )
460
+ parser.add_argument(
461
+ '-f',
462
+ '--force',
463
+ action='store_true',
464
+ help='force removal of worktree + branch even if '
465
+ 'untracked or unmerged changes exist.',
466
+ )
467
+ parser.add_argument(
468
+ 'worktree',
469
+ default='',
470
+ nargs='?',
471
+ help='worktree + branch name to remove. If not specified then '
472
+ 'fuzzy finder will prompt with a list of worktrees.',
473
+ )
474
+
475
+ @staticmethod
476
+ def run(args: Namespace) -> str | None:
477
+ Trees.fetch(args)
478
+ if not (name := args.worktree):
479
+ if not (path := Trees.prompt(args)):
480
+ return None
481
+ name = path.name
482
+
483
+ if not (path := Trees.remove_worktree(name, args)):
484
+ return None
485
+
486
+ return str(path)
487
+
488
+
489
+ # COMMAND
490
+ class cd_:
491
+ "Change directory to specified worktree."
492
+
493
+ aliases = ('c',)
494
+
495
+ @staticmethod
496
+ def init(parser: ArgumentParser) -> None:
497
+ parser.add_argument(
498
+ 'worktree',
499
+ default='',
500
+ nargs='?',
501
+ help='Worktree name to change directory to. If not specified then '
502
+ 'fuzzy finder will prompt with a list of worktrees.',
503
+ )
504
+
505
+ @staticmethod
506
+ def run(args: Namespace) -> str | None:
507
+ Trees.fetch(args)
508
+ if name := args.worktree:
509
+ if name in TOPLEVEL_SHORTCUTS:
510
+ path = Trees.toplevel.path
511
+ elif tree := Trees.get_tree(name):
512
+ path = tree.path
513
+ else:
514
+ sys.exit(f'error: no worktree found with name "{name}".')
515
+ else:
516
+ path = Trees.prompt(args)
517
+
518
+ return str(path) if path else None
519
+
520
+
521
+ # COMMAND
522
+ class ls_:
523
+ "List current worktrees."
524
+
525
+ aliases = ('l',)
526
+
527
+ @staticmethod
528
+ def run(args: Namespace) -> str | None:
529
+ Trees.fetch(args)
530
+ trees = Trees.get_trees()
531
+ with open('/dev/tty', 'w') as tty:
532
+ for line in reversed(trees):
533
+ print(line, file=tty)
534
+
535
+ return None
536
+
537
+
538
+ # COMMAND
539
+ class init_:
540
+ doc = f"""
541
+ Output shell initialization code.
542
+ Must be invoked using `source <({PROG})` in your shell `~/.bashrc` or
543
+ `~/.zshrc` initialization file to create the shell alias/function by which
544
+ you invoke this program.
545
+ """
546
+
547
+ @staticmethod
548
+ def init(parser: ArgumentParser) -> None:
549
+ parser.add_argument(
550
+ 'command',
551
+ nargs='?',
552
+ default=DEFCMD,
553
+ help='alternative command name, and optional default arguments, default="%(default)s"',
554
+ )
555
+
556
+ @staticmethod
557
+ def run(args: Namespace) -> str | None:
558
+ if args._:
559
+ sys.exit(
560
+ f'Must invoke using "{PROG}", not shell function, to output shell initialization code.'
561
+ )
562
+
563
+ return init_code(args.command)
564
+
565
+
566
+ if __name__ == '__main__':
567
+ sys.exit(main())