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
|
+
[](https://pypi.org/project/worktree-aid/)
|
|
15
|
+
[](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 @@
|
|
|
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())
|