-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgw.sh
More file actions
executable file
·607 lines (522 loc) · 21.3 KB
/
Copy pathgw.sh
File metadata and controls
executable file
·607 lines (522 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
#!/usr/bin/env bash
# gw - A Terminal User Interface wrapper to make Git worktrees easier to manage
# NOTE: We intentionally do NOT set -e here because dialog-driven control flow relies
# on inspecting exit codes (e.g. cancel vs OK). We add pipefail for safer pipes.
set -o pipefail
[ "${DEBUG:-0}" = "1" ] && set -x
declare -a worktree_list gw_commandlist
# worktree_list stores pairs: path branch path branch ... (Bash 3.x friendly)
# GW_SORT_MODE controls how the switch list is sorted
# Values: "default" (git's order) or "date" (by last commit date, newest first)
GW_SORT_MODE="${GW_SORT_MODE:-default}"
GW_VERSION="1.0.0"
DIALOG_OK=0
# Dialog exit/status codes reference (only DIALOG_OK currently used in logic):
# DIALOG_ERROR=-1 Error from dialog
# DIALOG_OK=0 User accepted (e.g. chose menu item / answered Yes)
# DIALOG_CANCEL=1 User pressed Cancel
# DIALOG_HELP=2 Help button (not wired here)
# DIALOG_ITEM_HELP=2 (Alias) Item help code
# DIALOG_EXTRA=3 Extra button (unused)
# DIALOG_TIMEOUT=5 Timed out (unused)
# DIALOG_ESC=255 ESC pressed (unused)
SCRIPT="$(basename "$0")"
gw_commandlist=(switch add convert remove list version)
_die () { _err "$*" ; exit 1 ; }
_err () { printf "%s: Error: %s\n" "$SCRIPT" "$*" ; }
_debug () { printf "%s: Debug: %s\n" "$SCRIPT" "$*" 1>&2 ; }
_errifnot () { if [ "$1" -ne "$2" ] ; then _debug "Error: return status $1" ; return 1 ; fi ; return 0 ; }
_get_worktree_list () {
# Populate global worktree_list with (path branch) pairs using git porcelain format.
# We parse only 'worktree' and 'branch' lines; a blank line terminates an entry.
worktree_list=()
local wt='' branch='' line
# Read git worktree list output line-by-line
while IFS= read -r line; do
# Blank line ends a record
if [ -z "$line" ]; then
if [ -n "$wt" ] && [ -n "$branch" ]; then
worktree_list+=("$wt" "$branch")
fi
wt='' branch=''
continue
fi
case "$line" in
worktree\ *)
# Everything after the first space is the path (may contain spaces)
wt="${line#worktree }"
;;
branch\ refs/heads/*)
branch="${line#branch refs/heads/}"
;;
branch\ *)
branch="${line#branch }"
;;
*)
: # ignore other keys
;;
esac
done < <(git worktree list --porcelain)
# Handle case where output does not end with newline
if [ -n "$wt" ] && [ -n "$branch" ]; then
worktree_list+=("$wt" "$branch")
fi
}
# Core logic for changing to a worktree directory
_gw_do_switch () {
local target_dir="$1"
local repo_prefix="$2"
local old_worktree_prefix="$3"
if [ ! -d "$target_dir" ]; then
_debug "No such directory '$target_dir'"
return 1
fi
echo "+ cd '$target_dir'"
# If we have a prefix from previous worktree, try to cd into it if it exists.
local prefixdir
prefixdir="${old_worktree_prefix:-$repo_prefix}"
if [ -n "$prefixdir" ] && [ -d "$target_dir/$prefixdir" ]; then
unset old_worktree_prefix
cd "$target_dir/$prefixdir" || _debug "Could not cd to '$target_dir/$prefixdir'"
else
if [ -n "$prefixdir" ] && [ ! -d "$target_dir/$prefixdir" ]; then
_debug "Could find '$prefixdir' in '$target_dir'; using repo root"
old_worktree_prefix="$prefixdir"
fi
cd "$target_dir" || _debug "Could not cd to '$target_dir'"
fi
}
# Get list of worktrees, prompt the user which one they want to enter,
# and then change to that directory
_gw_switch () {
local repo_prefix current_branch
repo_prefix="$(git rev-parse --show-prefix)"
current_branch="$(git rev-parse --abbrev-ref HEAD)"
_get_worktree_list
if [ ${#worktree_list[@]} -eq 0 ]; then
_debug "No worktrees found"
return 1
fi
if [ $# -gt 0 ]; then
local target_branch="$1"
local i path branch
for ((i=0; i<${#worktree_list[@]}; i+=2)); do
path="${worktree_list[i]}"
branch="${worktree_list[i+1]}"
if [ "$branch" = "$target_branch" ]; then
_gw_do_switch "$path" "$repo_prefix" ""
return $?
fi
done
_debug "Worktree branch '$target_branch' not found"
return 1
fi
_check_dialog
# Sort worktree list by commit date if requested
if [ "$GW_SORT_MODE" = "date" ]; then
local -a sorted_list=() dates_paths=()
local i path branch commit_date timestamp
# Collect dates with timestamps for sorting
for ((i=0; i<${#worktree_list[@]}; i+=2)); do
path="${worktree_list[i]}"
branch="${worktree_list[i+1]}"
timestamp="$(git -C "$path" log -1 --format=%ct 2>/dev/null || echo '0')"
dates_paths+=("$timestamp|$path|$branch")
done
# Sort by timestamp (newest first) and rebuild worktree_list
worktree_list=()
while IFS='|' read -r ts path branch; do
worktree_list+=("$path" "$branch")
done < <(printf '%s\n' "${dates_paths[@]}" | sort -t'|' -k1,1rn)
fi
# Build dialog menu args
local prompt
prompt="$(printf "%s\n" \
"Select a worktree branch to change current directory to." \
"" \
"Current branch is indicated with an asterisk.")"
# First pass: find max branch name length
local i=0 max_len=0 target_branch
for ((i=0; i<${#worktree_list[@]}; i+=2)); do
target_branch="${worktree_list[i+1]}"
[ ${#target_branch} -gt $max_len ] && max_len=${#target_branch}
done
# Build menu with aligned DEFAULT entry
local default_label padding
padding=$((max_len - 7)) # 9 = length of "(DEFAULT)", minus 2 due to column structure
printf -v default_label "(DEFAULT)%*s YYYY-MM-DD" $padding ""
local menu=(dialog --title "Worktree switch" --menu "$prompt" 0 0 0 -1 "$default_label")
# Second pass: build menu with aligned dates
local label target_dir commit_date
for ((i=0; i<${#worktree_list[@]}; i+=2)); do
target_dir="${worktree_list[i]}"; target_branch="${worktree_list[i+1]}"
commit_date="$(git -C "$target_dir" log -1 --format=%cd --date=short 2>/dev/null || echo 'N/A')"
if [ "$current_branch" = "$target_branch" ]; then
label="* $target_branch"
else
label=" $target_branch"
fi
# Pad to align dates
padding=$((max_len - ${#target_branch}))
printf -v label "%s%*s %s" "$label" $padding "" "$commit_date"
menu+=("$i" "$label")
done
# Show menu and get selection
local tmpfile selection old_worktree_prefix
tmpfile="$(mktemp)" || return 1
"${menu[@]}" 2>"$tmpfile"
_errifnot $? $DIALOG_OK || { rm -f "$tmpfile"; return 1; }
selection="$(cat "$tmpfile")"; rm -f "$tmpfile"
[ "$selection" = "-1" ] && return 0
# Change to selected worktree directory
target_dir="${worktree_list[selection]}"
_gw_do_switch "$target_dir" "$repo_prefix" "$old_worktree_prefix"
}
# Add a new worktree, optionally creating a new branch
_gw_add () {
local gitroot current_branch
gitroot="$(git rev-parse --show-toplevel)" || return 1
current_branch="$(git rev-parse --abbrev-ref HEAD)" || return 1
local create_flag origin_branch new_branch
if [ $# -gt 0 ]; then
if [ "$1" = "-b" ]; then
create_flag="y"
new_branch="$2"
origin_branch="${3:-$current_branch}"
if [ -z "$new_branch" ]; then
_debug "Usage: gw add -b <new_branch> [origin_branch]"
return 1
fi
else
create_flag="n"
origin_branch="$1"
new_branch=""
fi
else
_check_dialog
# Dialog form collects three fields (newline separated in tmp file):
# 0 -> create flag (y/n)
# 1 -> origin branch (base branch or existing branch)
# 2 -> new branch name (iff create flag = y)
local formdesc
formdesc="$(printf "%s\n" \
"Specify the following to add a new git worktree:" \
" 1) 'Create new branch?' - put 'y' to create a new branch, and fill out the 'New branch' section." \
" 2) 'Origin branch' - If not creating a new branch, this is the branch to use. If creating a new branch, this is the origin branch used to start a new branch." \
" 3) 'New branch' - The new branch name, if created.")"
local execlist=(
dialog --title "Add a new worktree" --form "$formdesc" 0 0 0 \
"Create new branch (y/n)" 1 1 "y" 1 25 30 0 \
"Origin branch" 2 1 "$current_branch" 2 25 99 0 \
"New branch" 3 1 "" 3 25 99 0
)
# Execute dialog and capture results
local tmpfile
local -a result=()
tmpfile="$(mktemp)" || return 1
"${execlist[@]}" 2>"$tmpfile"
_errifnot $? $DIALOG_OK || { rm -f "$tmpfile"; return 1; }
while IFS= read -r line; do
[ -z "$line" ] && continue
result+=("$line")
done <"$tmpfile"
rm -f "$tmpfile"
create_flag="${result[0]:-}"
origin_branch="${result[1]:-}"
new_branch="${result[2]:-}"
fi
if [ -z "$origin_branch" ]; then
_debug "No origin branch name"; return 1
fi
# Build the git worktree add command
local -a args=(git worktree add)
local path parent_dir
parent_dir="$(dirname "$gitroot")"
case "$create_flag" in
y|Y)
if [ -z "$new_branch" ]; then
_debug "No new branch name"; return 1
fi
path="$parent_dir/$new_branch"
args+=("-b" "$new_branch" "$path" "$origin_branch")
;;
*)
path="$parent_dir/$origin_branch"
args+=("$path" "$origin_branch")
;;
esac
echo "+ ${args[*]}"
# Execute the git worktree add command
if "${args[@]}" ; then
echo "+ cd '$path'"
cd "$path" || return 1
fi
}
# Convert a single-branch repo into a worktree-compatible layout.
# Moves all files into a new subdirectory named for the current branch.
# After conversion, the original root contains only branch directories + .gwrc.
# Requires user confirmation; aborts if destination exists or parent already has .gwrc.
_gw_convert () {
local force_yes=0
if [ "$1" = "-y" ] || [ "$1" = "--yes" ]; then
force_yes=1
fi
local execlist=() gitroot branchname newdir prompttxt dest dest_parent top_component
gitroot="$(git rev-parse --show-toplevel)" || return 1
branchname="$(git rev-parse --abbrev-ref HEAD)" || return 1
dest="$gitroot/$branchname"
dest_parent="$(dirname "$dest")"
top_component="${branchname%%/*}"
# Preconditions
if [ -e "$(dirname "$gitroot")/.gwrc" ] ; then
_debug "Parent is already a gw directory; conversion likely unnecessary"
return 1
fi
if [ -e "$dest" ] ; then
_debug "Destination '$dest' already exists; aborting convert"
return 1
fi
if [ $force_yes -eq 0 ]; then
_check_dialog
# Confirm with user
prompttxt="$( printf "%s\n" \
"Repository: $gitroot" \
"Remote:" "$(git remote -v)" \
"" \
"Will create branch directory: $dest" \
"'convert' copies ALL files (tracked, untracked, ignored) including .git into a new directory," \
"then removes originals from the parent, leaving only branch directories + .gwrc." \
"" \
"Continue?" )"
execlist=(dialog --title "Convert this repository to a worktree subdirectory" --yesno "$prompttxt" 0 0)
"${execlist[@]}"
_errifnot $? $DIALOG_OK || return 1
fi
# Create temporary directory for copying files
newdir="$(mktemp -d)" || { _debug "Failed to allocate temp dir"; return 1; }
# Copy phase: include hidden and regular entries (except . and ..). Use dotglob/nullglob safely.
(
shopt -s dotglob nullglob
local items=(*)
if [ ${#items[@]} -eq 0 ] ; then
_debug "Nothing to copy? (Empty directory)"; exit 1
fi
cp -a "${items[@]}" "$newdir/" || exit 1
) || { _debug "Copy phase failed"; return 1; }
# Create destination parent path (supports branch names with slashes)
mkdir -p "$dest_parent" || { _debug "Failed to create parent '$dest_parent'"; return 1; }
# Move the copied snapshot into its final branch directory
if ! mv "$newdir" "$dest" ; then
_debug "Move of '$newdir' to '$dest' failed"
return 1
fi
# Write/overwrite .gwrc at parent to mark origin workdir
echo "ORIGIN_WORKDIR=\"$dest\"" > .gwrc || _debug "Failed writing .gwrc"
# Remove original items from root EXCEPT the branch top component (and .gwrc we just wrote)
(
shopt -s dotglob nullglob
for f in *; do
[ "$f" = "$top_component" ] && continue
[ "$f" = ".gwrc" ] && continue
rm -rf -- "$f"
done
)
cd "$branchname" || _debug "Converted but failed to cd into branch dir '$branchname'"
}
# Remove a worktree (by branch) after switching to a fallback if removing current.
_gw_remove () {
local force_yes=0
local target_branch=""
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) force_yes=1 ;;
*) target_branch="$1" ;;
esac
shift
done
local execlist=() gitroot branchname prompttxt cur_path alt_path
gitroot="$(git rev-parse --show-toplevel)" || return 1
branchname="$(git rev-parse --abbrev-ref HEAD)" || return 1
local remove_branch="${target_branch:-$branchname}"
_get_worktree_list
if [ ${#worktree_list[@]} -lt 2 ]; then
_debug "No worktree entries found"; return 1
fi
# Scan pair list: path branch
local i path branch
for ((i=0; i<${#worktree_list[@]}; i+=2)); do
path="${worktree_list[i]}"; branch="${worktree_list[i+1]}"
if [ "$branch" = "$remove_branch" ]; then
cur_path="$path"
elif [ -z "${alt_path:-}" ]; then
alt_path="$path"
fi
done
if [ -z "${cur_path:-}" ]; then _debug "Could not find worktree path for branch '$remove_branch'"; return 1; fi
if [ $force_yes -eq 0 ]; then
_check_dialog
# Confirm with user
prompttxt="$( printf "%s\n" \
"Repository: $gitroot" \
"Remote:" "$(git remote -v)" \
"" \
"Worktree path to remove: $cur_path" \
"$( [ "$remove_branch" = "$branchname" ] && echo "Switching to: $alt_path" )" \
"" \
"Remove worktree for branch '$remove_branch'?" )"
execlist=(dialog --title "Remove worktree" --yesno "$prompttxt" 0 0)
"${execlist[@]}"
_errifnot $? $DIALOG_OK || return 1
fi
# If removing the current worktree, switch to alternate first
if [ "$remove_branch" = "$branchname" ]; then
if [ -z "${alt_path:-}" ]; then _debug "Refusing to remove the only remaining worktree"; return 1; fi
cd "$alt_path" || { _debug "Failed to cd to '$alt_path'"; return 1; }
fi
# Finally, remove the worktree
git worktree remove "$cur_path"
}
# Show a simple dialog listing current worktrees; handle empty edge case.
_gw_list () {
local list
if ! list="$(git worktree list -v 2>/dev/null)" ; then
[ $# -eq 0 ] && command -v dialog >/dev/null && dialog --title "Worktree list" --msgbox "(Error running 'git worktree list')" 0 0
_debug "Error running 'git worktree list'"
return 1
fi
if [ -z "$list" ]; then
[ $# -eq 0 ] && command -v dialog >/dev/null && dialog --title "Worktree list" --msgbox "No worktrees found." 0 0
_debug "No worktrees found"
return 0
fi
if [ $# -gt 0 ] || ! command -v dialog >/dev/null || ! [ -t 1 ] ; then
echo "$list"
else
local tmpfile
tmpfile="$(mktemp)" || return 1
echo "$list" > "$tmpfile"
dialog --title "Worktree list" --textbox "$tmpfile" 0 0
rm -f "$tmpfile"
fi
}
# Main interactive menu for selecting a subcommand.
# Builds a numeric menu of entries in gw_commandlist, runs chosen command.
_gw () {
_check_dialog
local prompt gitroot
gitroot="$(git rev-parse --show-toplevel)" || return 1
prompt="$(printf "%s\n" "Repository: $gitroot" "" "Select a wrapper command")"
# Build menu args
local menu=() idx=0
menu=(dialog --title "Git Worktree wrapper" --menu "$prompt" 0 0 0 -1 "(NONE)")
for cmd in "${gw_commandlist[@]}"; do
menu+=("$idx" "$cmd")
idx=$((idx+1))
done
# Show menu and get selection
local tmpfile
tmpfile="$(mktemp)" || return 1
"${menu[@]}" 2>"$tmpfile"
_errifnot $? $DIALOG_OK || { rm -f "$tmpfile"; return 1; }
local selection
selection="$(cat "$tmpfile")"
rm -f "$tmpfile"
[ "$selection" = "-1" ] && return 0
# Run the selected command
GW_INTERACTIVE=1 _gw_runcmd "${gw_commandlist[$selection]}"
}
# Run a specific command (non-interactive mode)
_gw_runcmd () {
local cmd="$1"
shift
case "$cmd" in
-h|--help) _gw_usage ;;
-v|--version|version) _gw_version ;;
sw|switch) _gw_switch "$@" ;;
a|add) _gw_add "$@" ;;
convert) _gw_convert "$@" ;;
remove) _gw_remove "$@" ;;
list) _gw_list "$@" ;;
*) _debug "Invalid command: '$cmd'" ;;
esac
}
# Check for required dependencies
_check_deps () {
local cmd
for cmd in git ; do
command -v "$cmd" >/dev/null || _die "Could not find command: $cmd"
done
}
_check_dialog () {
command -v dialog >/dev/null || _die "Could not find command: dialog (required for interactive mode)"
}
_gw_version () {
local version_str
version_str="gw version $GW_VERSION"
if [ "${GW_INTERACTIVE:-0}" = "1" ] && [ -t 1 ] && command -v dialog >/dev/null; then
dialog --title "Version" --msgbox "$version_str" 0 0
else
printf "%s\n" "$version_str"
fi
}
_gw_usage () {
cat <<EOUSAGE
gw: Bash wrapper around 'git worktree' (version $GW_VERSION)
(Source this script into your shell with: \`source $SCRIPT\` ;
then use the \`gw\` command)
Usage: gw [COMMAND] [ARGS]
1. Keep a directory 'foo', and in that directory clone a Git repository, with the
name of your main branch (so, 'foo/main').
2. Change to that directory and run 'gw' (if 'gw' is in your path, this will find
'gw' and load it into your shell, which will enable it to change the current
directory of your shell).
3. You can then select a command to run and the wrapper will make it easier to use.
Commands:
version Show the current version of 'gw'.
switch [branch] Switch to a worktree directory. Looks up your worktree list,
presents you with a list of branches, and when you select one,
your current shell will change to the directory of that worktree.
If [branch] is provided, it switches directly to that branch.
add [-b <new>] [orig]
Add a new worktree directory. Put in the name of the source branch
and the name of a new branch, and a new worktree will be created
with the new branch name (ex. 'foo/new-branch').
If -b <new> is provided, it creates a new branch <new> from [orig].
If only [orig] is provided, it adds a worktree for existing branch [orig].
convert [-y] Convert a git repository into a worktree-compatible form. Basically
it just makes a new temp directory, copies all the files in the current
directory there, removes all the files in the current directory, and
then moves the temp directory into the current one with the name of the
branch that was previously checked out. From here you can run workdir
commands and they will create directories in a parent directory
(using the name of the branch you want a worktree for).
Use -y to skip confirmation.
remove [-y] [branch]
Remove a worktree. By default it removes the current worktree.
If [branch] is provided, it removes the worktree for that branch.
Use -y to skip confirmation.
list List the current worktrees.
EOUSAGE
return 1
}
# Run the 'gw' command after sourcing into your shell
gw () {
_check_deps
# If .gwrc exists in current directory, source it to get ORIGIN_WORKDIR and cd there.
if [ -r .gwrc ] ; then
# shellcheck disable=SC2016 # We intentionally use single quotes to prevent expansion in this shell; subshell handles it.
origin_workdir="$(env -i sh -c 'set -a; . ./.gwrc ; echo $ORIGIN_WORKDIR')"
_debug "Found .gwrc; moving to origin worktree '$origin_workdir'"
cd "$origin_workdir" || _debug "Failed to cd to origin worktree '$origin_workdir'"
fi
if ! git rev-parse 2>/dev/null ; then
_debug "Current directory is not a Git work tree"
return 1
fi
# If no args, run interactive menu; else run specified command.
if [ $# -lt 1 ] ; then
_gw
else
_gw_runcmd "$@"
fi
}