-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-turnouts
More file actions
executable file
·2321 lines (2020 loc) · 73.3 KB
/
git-turnouts
File metadata and controls
executable file
·2321 lines (2020 loc) · 73.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
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# Git Turnouts - Your Git branch switching yard
# Manage Git worktrees with intelligent GitHub Pull Request integration
set -e
VERSION="1.1.0"
# ============================================================================
# SHARED HELPER FUNCTIONS
# ============================================================================
# Function to get PR branch by title (exact or partial match)
get_pr_branch_by_title() {
local search_term="$1"
local exact_match="$2"
if [ "$exact_match" = "true" ]; then
# Exact match - PR title must exactly match the search term
gh pr list --json number,title,headRefName | \
jq -r --arg term "$search_term" '.[] | select(.title == $term) | .headRefName' | \
head -n1
else
# Partial match - PR title must contain the search term
gh pr list --json number,title,headRefName | \
jq -r --arg term "$search_term" '.[] | select(.title | contains($term)) | .headRefName' | \
head -n1
fi
}
# Function to get PR details by PR number
get_pr_details_by_number() {
local pr_number="$1"
# Get PR details including state and merge information
gh pr view "$pr_number" --json headRefName,state,mergedAt,baseRefName 2>/dev/null
}
# Function to get PR branch by PR number
get_pr_branch_by_number() {
local pr_number="$1"
# Get PR details by number
gh pr view "$pr_number" --json headRefName 2>/dev/null | \
jq -r '.headRefName' 2>/dev/null
}
# Function to check if a string is a PR number (all digits)
is_pr_number() {
local input="$1"
[[ "$input" =~ ^[0-9]+$ ]]
}
# Function to check if a string is quoted and strip quotes
check_and_strip_quotes() {
local input="$1"
# Check if string starts with " or ' and ends with the same quote
if [[ "$input" =~ ^\"(.*)\"$ ]] || [[ "$input" =~ ^\'(.*)\'$ ]]; then
echo "${BASH_REMATCH[1]}"
return 0 # Was quoted
else
echo "$input"
return 1 # Was not quoted
fi
}
# Get the main worktree path
get_main_worktree() {
git worktree list | head -n1 | awk '{print $1}'
}
# Get project name from main repository
get_project_name() {
local main_worktree=$(get_main_worktree)
basename "$main_worktree"
}
# Get base worktree directory
# Returns the base directory where worktrees should be created
# The project name will be automatically added as a subdirectory
get_base_worktree_dir() {
local project_name=$(get_project_name)
# Use hierarchical lookup: project-specific → global → auto-detect
local base_dir=$(get_config "worktree.base_dir" "" "$project_name")
if [ -n "$base_dir" ]; then
echo "$base_dir"
else
# Fallback to automatic detection (current behavior)
local main_worktree=$(get_main_worktree)
echo "$(dirname "$main_worktree")/worktree"
fi
}
# Get absolute path (portable alternative to realpath)
# Works on all Unix systems without requiring the realpath command
get_absolute_path() {
local path="$1"
# If path is empty, return empty
if [ -z "$path" ]; then
echo ""
return
fi
# Expand tilde to home directory
case "$path" in
"~/"*)
path="${HOME}/${path#\~/}"
;;
"~")
path="$HOME"
;;
esac
# If path exists as a directory, cd into it and get pwd
if [ -d "$path" ]; then
(cd "$path" && pwd)
# If path doesn't exist yet, resolve the parent directory
elif [ -e "$path" ]; then
# Path exists as a file
local dir=$(dirname "$path")
local base=$(basename "$path")
echo "$(cd "$dir" 2>/dev/null && pwd)/$base"
else
# Path doesn't exist yet - resolve what we can
local dir=$(dirname "$path")
local base=$(basename "$path")
if [ -d "$dir" ]; then
echo "$(cd "$dir" && pwd)/$base"
else
# Even parent doesn't exist, return as-is
# This will be created by mkdir -p later
echo "$path"
fi
fi
}
# ============================================================================
# REMOTE BRANCH VERIFICATION FUNCTIONS
# ============================================================================
# Check if a branch exists on the remote
# Arguments: $1 = branch_name
# Returns: 0 if exists, 1 if not
check_remote_branch_exists() {
local branch="$1"
# Check if remote tracking ref exists locally
if git show-ref --verify --quiet "refs/remotes/origin/$branch"; then
return 0
else
return 1
fi
}
# Check if local branch has unpushed commits
# Arguments: $1 = branch_name
# Sets global: UNPUSHED_COMMIT_COUNT
# Returns: 0 if has unpushed commits, 1 if not or branch deleted
check_unpushed_commits() {
local branch="$1"
# Check if remote branch exists
if ! git show-ref --verify --quiet "refs/remotes/origin/$branch"; then
UNPUSHED_COMMIT_COUNT=0
return 1
fi
# Count commits ahead of remote
UNPUSHED_COMMIT_COUNT=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || echo "0")
if [ "$UNPUSHED_COMMIT_COUNT" -gt 0 ]; then
return 0
else
return 1
fi
}
# Check if a branch is protected
# Arguments: $1 = branch name
# Returns: 0 (true) if protected, 1 (false) if not protected
is_protected_branch() {
local branch="$1"
# Get default branch
local default_branch
default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
# Get protected branches (project-specific → global → default: main master)
local protected_branches=$(get_config "protection.protected_branches" "main master")
# Check against configured protected branches
for protected in $protected_branches $default_branch; do
if [ "$branch" = "$protected" ]; then
return 0 # Protected
fi
done
return 1 # Not protected
}
# Analyze all worktrees for remote branch status
# Arguments: $1 = verbose (true/false)
# Sets globals: WORKTREES_WITH_REMOTE, WORKTREES_WITHOUT_REMOTE, WORKTREES_PROTECTED_STALE, WORKTREE_PATHS
analyze_worktrees_remote_status() {
local verbose="${1:-false}"
# Initialize result variables
WORKTREES_WITH_REMOTE=""
WORKTREES_WITHOUT_REMOTE=""
WORKTREES_PROTECTED_STALE=""
WORKTREE_PATHS=""
# Get the main worktree path (the repository root)
local main_worktree_path=$(git rev-parse --show-toplevel)
# Parse git worktree list
while IFS= read -r line; do
# Skip lines without branch info (shouldn't happen with git worktree list)
if [[ ! "$line" =~ \[([^\]]+)\] ]]; then
continue
fi
local branch="${BASH_REMATCH[1]}"
local path=$(echo "$line" | awk '{print $1}')
# Skip the primary/main worktree (the repo itself)
# Compare paths by resolving both to absolute paths
local resolved_path=$(cd "$path" 2>/dev/null && pwd || echo "$path")
local resolved_main=$(cd "$main_worktree_path" 2>/dev/null && pwd || echo "$main_worktree_path")
if [ "$resolved_path" = "$resolved_main" ]; then
if [ "$verbose" = "true" ]; then
echo "⏭️ $branch → skipping main worktree"
fi
continue
fi
# Store path mapping
WORKTREE_PATHS="$WORKTREE_PATHS ${branch}:${path}"
# Check remote status
if check_remote_branch_exists "$branch"; then
WORKTREES_WITH_REMOTE="$WORKTREES_WITH_REMOTE $branch"
if [ "$verbose" = "true" ]; then
# Check if this active branch is also protected
if is_protected_branch "$branch"; then
echo "✅ $branch → origin/$branch exists (PROTECTED)"
else
echo "✅ $branch → origin/$branch exists"
fi
fi
else
# Remote branch deleted - check if protected
if is_protected_branch "$branch"; then
WORKTREES_PROTECTED_STALE="$WORKTREES_PROTECTED_STALE $branch"
if [ "$verbose" = "true" ]; then
echo "🛡️ $branch → branch deleted from remote (PROTECTED - will not be removed)"
fi
else
WORKTREES_WITHOUT_REMOTE="$WORKTREES_WITHOUT_REMOTE $branch"
if [ "$verbose" = "true" ]; then
echo "❌ $branch → branch deleted from remote"
fi
fi
fi
done < <(git worktree list)
# Trim leading spaces
WORKTREES_WITH_REMOTE="${WORKTREES_WITH_REMOTE## }"
WORKTREES_WITHOUT_REMOTE="${WORKTREES_WITHOUT_REMOTE## }"
WORKTREES_PROTECTED_STALE="${WORKTREES_PROTECTED_STALE## }"
WORKTREE_PATHS="${WORKTREE_PATHS## }"
}
# ============================================================================
# CONFIGURATION SYSTEM
# ============================================================================
# Configuration variables (bash 3.2 compatible - no associative arrays)
# All configuration sections now support both global and project-specific settings
# Format: "project1:value1 project2:value2"
# Worktree configuration
CONFIG_WORKTREE_GLOBAL_BASE_DIR="" # Global base directory (project name added automatically)
CONFIG_WORKTREE_PROJECTS="" # Space-separated: "project1:path1 project2:path2"
CONFIG_WORKTREE_GLOBAL_COPY_FILES="" # Space-separated list of global copy files
CONFIG_WORKTREE_PROJECTS_COPY_FILES="" # Space-separated: "project1:file1,file2 project2:file3"
# Defaults configuration
CONFIG_DEFAULTS_GLOBAL_OPEN_WITH="" # No default - opening is opt-in
CONFIG_DEFAULTS_PROJECTS="" # Space-separated: "project1:editor1 project2:editor2"
# Protection configuration
CONFIG_PROTECTION_GLOBAL_BRANCHES="main master" # Hardcoded defaults (always protected)
CONFIG_PROTECTION_PROJECTS="" # Space-separated: "project1:dev,staging project2:prod"
# Remove configuration
CONFIG_REMOVE_GLOBAL_AUTO_PRUNE="true" # Hardcoded default
CONFIG_REMOVE_PROJECTS="" # Space-separated: "project1:false project2:true"
# Parse simple YAML configuration (bash 3.2 compatible)
# Supports hierarchical structure: section.global.key and section.projects[].key
# All sections support both global and project-specific settings
parse_yaml_config() {
local config_file="$1"
local section="" # Level 1: global, projects
local in_project_item=false # Are we inside a project list item?
local in_list=false # Are we inside a list (copy_files, protected_branches)?
local list_type="" # What type of list (copy_files, protected_branches)
# Temporary variables for building project entries
local current_project_name=""
local current_base_dir=""
local current_open_with=""
local current_auto_prune=""
local current_copy_files=""
local current_protected_branches=""
while IFS= read -r line || [ -n "$line" ]; do
# Skip comments and empty lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// /}" ]] && continue
# No indent: Section headers (e.g., "global:", "projects:")
if [[ "$line" =~ ^([a-z_]+):([[:space:]]*|[[:space:]]+.*)$ ]]; then
# Save any pending project data before switching sections
save_project_data
section="${BASH_REMATCH[1]}"
in_list=false
in_project_item=false
continue
fi
# 2-space indent, key:value: Keys under global (e.g., " base_dir: ~/foo")
if [[ "$line" =~ ^[[:space:]]{2}([a-z_]+):[[:space:]]*(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Expand tilde in paths
if [[ "$value" == ~* ]]; then
value="${value/#\~/$HOME}"
fi
if [ "$section" = "global" ]; then
# Handle global settings
if [ -z "$value" ]; then
# It's a list header (e.g., " copy_files:" or " protected_branches:")
in_list=true
list_type="$key"
else
# It's a direct key-value pair
case "$key" in
"base_dir")
CONFIG_WORKTREE_GLOBAL_BASE_DIR="$value"
;;
"open_with")
CONFIG_DEFAULTS_GLOBAL_OPEN_WITH="$value"
;;
"auto_prune")
CONFIG_REMOVE_GLOBAL_AUTO_PRUNE="$value"
;;
esac
fi
fi
continue
fi
# 2-space indent, dash: Project list items (e.g., " - name: my-app")
if [[ "$line" =~ ^[[:space:]]{2}-[[:space:]]*(.*)$ ]]; then
local rest="${BASH_REMATCH[1]}"
if [ "$section" = "projects" ]; then
# Save previous project before starting a new one
save_project_data
# Start new project item
in_project_item=true
current_project_name=""
current_base_dir=""
current_open_with=""
current_auto_prune=""
current_copy_files=""
current_protected_branches=""
in_list=false
# Check if name: is on the same line as dash
if [[ "$rest" =~ ^name:[[:space:]]*(.+)$ ]]; then
current_project_name="${BASH_REMATCH[1]}"
fi
fi
continue
fi
# 4-space indent, dash: List items under global (e.g., " - .editorconfig")
if [[ "$line" =~ ^[[:space:]]{4}-[[:space:]]*(.*)$ ]]; then
local item="${BASH_REMATCH[1]}"
if [ "$in_list" = true ] && [ "$section" = "global" ]; then
# List item under global (e.g., copy_files or protected_branches)
case "$list_type" in
"copy_files")
CONFIG_WORKTREE_GLOBAL_COPY_FILES="$CONFIG_WORKTREE_GLOBAL_COPY_FILES $item"
;;
"protected_branches")
CONFIG_PROTECTION_GLOBAL_BRANCHES="$CONFIG_PROTECTION_GLOBAL_BRANCHES $item"
;;
esac
fi
continue
fi
# 4-space indent, key:value: Project properties (e.g., " base_dir: ~/custom")
if [[ "$line" =~ ^[[:space:]]{4}([a-z_]+):[[:space:]]*(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Expand tilde in paths
if [[ "$value" == ~* ]]; then
value="${value/#\~/$HOME}"
fi
if [ "$in_project_item" = true ]; then
if [ -z "$value" ]; then
# It's a list header under project (e.g., " copy_files:")
in_list=true
list_type="$key"
else
# It's a direct value under project (base_dir, open_with, auto_prune)
case "$key" in
"base_dir")
current_base_dir="$value"
;;
"open_with")
current_open_with="$value"
;;
"auto_prune")
current_auto_prune="$value"
;;
esac
fi
fi
continue
fi
# 6-space indent, dash: List items under project properties (e.g., " - .editorconfig")
if [[ "$line" =~ ^[[:space:]]{6}-[[:space:]]*(.*)$ ]]; then
local item="${BASH_REMATCH[1]}"
if [ "$in_project_item" = true ] && [ "$in_list" = true ]; then
# Add to project's list (comma-separated)
case "$list_type" in
"copy_files")
if [ -z "$current_copy_files" ]; then
current_copy_files="$item"
else
current_copy_files="$current_copy_files,$item"
fi
;;
"protected_branches")
if [ -z "$current_protected_branches" ]; then
current_protected_branches="$item"
else
current_protected_branches="$current_protected_branches,$item"
fi
;;
esac
fi
continue
fi
done < "$config_file"
# Save any remaining project data
save_project_data
# Trim leading spaces from global lists
CONFIG_WORKTREE_GLOBAL_COPY_FILES="${CONFIG_WORKTREE_GLOBAL_COPY_FILES## }"
CONFIG_PROTECTION_GLOBAL_BRANCHES="${CONFIG_PROTECTION_GLOBAL_BRANCHES## }"
CONFIG_WORKTREE_PROJECTS="${CONFIG_WORKTREE_PROJECTS## }"
CONFIG_DEFAULTS_PROJECTS="${CONFIG_DEFAULTS_PROJECTS## }"
CONFIG_PROTECTION_PROJECTS="${CONFIG_PROTECTION_PROJECTS## }"
CONFIG_REMOVE_PROJECTS="${CONFIG_REMOVE_PROJECTS## }"
CONFIG_WORKTREE_PROJECTS_COPY_FILES="${CONFIG_WORKTREE_PROJECTS_COPY_FILES## }"
}
# Helper function to save accumulated project data
save_project_data() {
if [ "$in_project_item" = true ] && [ -n "$current_project_name" ]; then
# Save base_dir if present
if [ -n "$current_base_dir" ]; then
CONFIG_WORKTREE_PROJECTS="$CONFIG_WORKTREE_PROJECTS ${current_project_name}:${current_base_dir}"
fi
# Save copy_files list if present
if [ -n "$current_copy_files" ]; then
CONFIG_WORKTREE_PROJECTS_COPY_FILES="$CONFIG_WORKTREE_PROJECTS_COPY_FILES ${current_project_name}:${current_copy_files}"
fi
# Save open_with if present
if [ -n "$current_open_with" ]; then
CONFIG_DEFAULTS_PROJECTS="$CONFIG_DEFAULTS_PROJECTS ${current_project_name}:${current_open_with}"
fi
# Save protected_branches if present
if [ -n "$current_protected_branches" ]; then
CONFIG_PROTECTION_PROJECTS="$CONFIG_PROTECTION_PROJECTS ${current_project_name}:${current_protected_branches}"
fi
# Save auto_prune if present
if [ -n "$current_auto_prune" ]; then
CONFIG_REMOVE_PROJECTS="$CONFIG_REMOVE_PROJECTS ${current_project_name}:${current_auto_prune}"
fi
# Reset for next project
current_project_name=""
current_base_dir=""
current_open_with=""
current_auto_prune=""
current_copy_files=""
current_protected_branches=""
in_list=false
fi
in_project_item=false
}
# Validate configuration values (bash 3.2 compatible)
validate_configuration() {
# No validation needed for open_with - any command name is accepted
# Validation happens at runtime when attempting to open
# Validate worktree.global.base_dir exists or can be created
if [ -n "$CONFIG_WORKTREE_GLOBAL_BASE_DIR" ]; then
if [ ! -d "$CONFIG_WORKTREE_GLOBAL_BASE_DIR" ]; then
local parent_dir=$(dirname "$CONFIG_WORKTREE_GLOBAL_BASE_DIR")
if [ ! -d "$parent_dir" ]; then
echo "⚠️ Warning: Configured worktree.global.base_dir parent does not exist: $parent_dir"
echo " Falling back to automatic detection"
CONFIG_WORKTREE_GLOBAL_BASE_DIR=""
fi
fi
fi
# Validate and normalize remove.global.auto_prune to true/false
case "$CONFIG_REMOVE_GLOBAL_AUTO_PRUNE" in
true|false)
;; # Already normalized
yes|1)
CONFIG_REMOVE_GLOBAL_AUTO_PRUNE="true"
;;
no|0)
CONFIG_REMOVE_GLOBAL_AUTO_PRUNE="false"
;;
*)
echo "⚠️ Warning: Invalid remove.global.auto_prune value '$CONFIG_REMOVE_GLOBAL_AUTO_PRUNE' in config"
echo " Valid options: true, false, yes, no, 1, 0"
echo " Falling back to default: true"
CONFIG_REMOVE_GLOBAL_AUTO_PRUNE="true"
;;
esac
# Validate and normalize project-specific auto_prune values
local validated_remove_projects=""
for entry in $CONFIG_REMOVE_PROJECTS; do
local proj_name="${entry%%:*}"
local auto_prune="${entry#*:}"
case "$auto_prune" in
true|false)
validated_remove_projects="$validated_remove_projects ${entry}"
;;
yes|1)
validated_remove_projects="$validated_remove_projects ${proj_name}:true"
;;
no|0)
validated_remove_projects="$validated_remove_projects ${proj_name}:false"
;;
*)
echo "⚠️ Warning: Invalid auto_prune value '$auto_prune' for project '$proj_name'"
echo " Valid options: true, false, yes, no, 1, 0"
echo " This project will use global setting"
;;
esac
done
CONFIG_REMOVE_PROJECTS="${validated_remove_projects## }"
}
# Load configuration from file (bash 3.2 compatible)
load_configuration() {
# Get the directory where git-turnouts is installed
# Resolve symlinks to find the actual script location
local script_path="${BASH_SOURCE[0]}"
while [ -L "$script_path" ]; do
local link_target=$(readlink "$script_path")
if [[ "$link_target" == /* ]]; then
script_path="$link_target"
else
script_path="$(dirname "$script_path")/$link_target"
fi
done
local script_dir="$(cd "$(dirname "$script_path")" && pwd)"
# Allow config file override via environment variable (useful for testing)
local config_file="${GIT_TURNOUTS_CONFIG:-$script_dir/.config.yml}"
# Defaults are already set in variable declarations above
# Only load and parse if config file exists
if [ -f "$config_file" ]; then
if [ -r "$config_file" ]; then
parse_yaml_config "$config_file" 2>/dev/null || {
echo "⚠️ Warning: Error parsing config file: $config_file"
echo " Continuing with defaults..."
}
# Validate configuration after loading
validate_configuration
else
echo "⚠️ Warning: Cannot read config file: $config_file"
echo " Continuing with defaults..."
fi
fi
}
# Get configuration value with fallback (bash 3.2 compatible)
# Supports hierarchical lookup: project-specific → global → hardcoded default
# Usage: get_config "key" "hardcoded_default" ["project_name"]
get_config() {
local key="$1"
local default="${2:-}"
local project="${3:-}" # Optional: specific project, otherwise auto-detect
# Auto-detect project if not provided
if [ -z "$project" ]; then
project=$(get_project_name 2>/dev/null || echo "")
fi
case "$key" in
# base_dir setting: project-specific → global → auto-detect
worktree.base_dir)
# 1. Check project-specific setting
if [ -n "$project" ]; then
for entry in $CONFIG_WORKTREE_PROJECTS; do
local proj_name="${entry%%:*}"
local proj_path="${entry#*:}"
if [ "$proj_name" = "$project" ]; then
echo "$proj_path"
return
fi
done
fi
# 2. Check global setting
if [ -n "$CONFIG_WORKTREE_GLOBAL_BASE_DIR" ]; then
echo "$CONFIG_WORKTREE_GLOBAL_BASE_DIR"
return
fi
# 3. Use hardcoded default
echo "$default"
;;
# copy_files setting: project-specific ADDS TO global (additive behavior)
worktree.copy_files)
local result=""
# 1. Start with global setting
if [ -n "$CONFIG_WORKTREE_GLOBAL_COPY_FILES" ]; then
result="$CONFIG_WORKTREE_GLOBAL_COPY_FILES"
fi
# 2. Add project-specific files (if any)
if [ -n "$project" ]; then
for entry in $CONFIG_WORKTREE_PROJECTS_COPY_FILES; do
local proj_name="${entry%%:*}"
local proj_files="${entry#*:}"
if [ "$proj_name" = "$project" ]; then
# Convert comma-separated to space-separated and append
local proj_files_space="${proj_files//,/ }"
if [ -n "$result" ]; then
result="$result $proj_files_space"
else
result="$proj_files_space"
fi
break
fi
done
fi
# 3. Return combined result or default
if [ -n "$result" ]; then
echo "$result"
else
echo "$default"
fi
;;
# open_with setting: project-specific → global → none (no automatic opening)
defaults.open_with)
# 1. Check project-specific setting
if [ -n "$project" ]; then
for entry in $CONFIG_DEFAULTS_PROJECTS; do
local proj_name="${entry%%:*}"
local proj_value="${entry#*:}"
if [ "$proj_name" = "$project" ]; then
echo "$proj_value"
return
fi
done
fi
# 2. Check global setting
if [ -n "$CONFIG_DEFAULTS_GLOBAL_OPEN_WITH" ]; then
echo "$CONFIG_DEFAULTS_GLOBAL_OPEN_WITH"
return
fi
# 3. Use hardcoded default
echo "$default"
;;
# protected_branches setting: project-specific ADDS TO global (additive behavior)
# Note: main and master are always included as base protection
protection.protected_branches)
local result="main master" # Always start with main and master
# 1. Add global branches
if [ -n "$CONFIG_PROTECTION_GLOBAL_BRANCHES" ]; then
# Global config already includes main/master from initialization
# Extract only the additional branches (skip main/master to avoid duplicates)
for branch in $CONFIG_PROTECTION_GLOBAL_BRANCHES; do
if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
result="$result $branch"
fi
done
fi
# 2. Add project-specific branches (if any)
if [ -n "$project" ]; then
for entry in $CONFIG_PROTECTION_PROJECTS; do
local proj_name="${entry%%:*}"
local proj_branches="${entry#*:}"
if [ "$proj_name" = "$project" ]; then
# Convert comma-separated to space-separated and append
result="$result ${proj_branches//,/ }"
break
fi
done
fi
# 3. Return combined result (or default if somehow empty)
if [ -n "$result" ]; then
echo "$result"
else
echo "$default"
fi
;;
# auto_prune setting: project-specific → global → hardcoded default
remove.auto_prune)
# 1. Check project-specific setting
if [ -n "$project" ]; then
for entry in $CONFIG_REMOVE_PROJECTS; do
local proj_name="${entry%%:*}"
local proj_value="${entry#*:}"
if [ "$proj_name" = "$project" ]; then
echo "$proj_value"
return
fi
done
fi
# 2. Check global setting
if [ -n "$CONFIG_REMOVE_GLOBAL_AUTO_PRUNE" ]; then
echo "$CONFIG_REMOVE_GLOBAL_AUTO_PRUNE"
return
fi
# 3. Use hardcoded default
echo "$default"
;;
# Legacy support for old key formats
worktree.global.base_dir)
echo "${CONFIG_WORKTREE_GLOBAL_BASE_DIR:-$default}"
;;
defaults.global.open_with)
echo "${CONFIG_DEFAULTS_GLOBAL_OPEN_WITH:-$default}"
;;
remove.global.auto_prune)
echo "${CONFIG_REMOVE_GLOBAL_AUTO_PRUNE:-$default}"
;;
*)
echo "$default"
;;
esac
}
# Copy configured files from main worktree to new worktree (bash 3.2 compatible)
copy_configured_files() {
local source_dir="$1" # Main worktree path
local target_dir="$2" # New worktree path
# Get copy_files configuration (project-specific → global → empty)
local copy_files=$(get_config "worktree.copy_files" "")
# Check if there are files to copy
if [ -z "$copy_files" ]; then
return 0
fi
echo "📄 Copying configured files..."
local copied=0
local skipped=0
# Iterate over space-separated list
for file in $copy_files; do
local source_file="$source_dir/$file"
local target_file="$target_dir/$file"
if [ -f "$source_file" ]; then
# Create target directory if needed
local target_dir_parent=$(dirname "$target_file")
mkdir -p "$target_dir_parent"
# Copy file
if cp "$source_file" "$target_file" 2>/dev/null; then
echo " ✓ Copied: $file"
copied=$((copied + 1))
else
echo " ⚠ Failed to copy: $file"
skipped=$((skipped + 1))
fi
else
echo " ⚠ Not found: $file (skipped)"
skipped=$((skipped + 1))
fi
done
if [ $copied -gt 0 ]; then
echo " Copied $copied file(s)"
fi
if [ $skipped -gt 0 ]; then
echo " Skipped $skipped file(s)"
fi
}
# ============================================================================
# ADD COMMAND - Create new worktrees
# ============================================================================
cmd_add() {
# Load default from config
local OPEN_APP=$(get_config "defaults.open_with" "")
# Function to show usage for add command
show_add_usage() {
echo "Usage: git-turnouts add <name> [branch-name] [--open <app>]"
echo ""
echo "Arguments:"
echo " <name>: Folder name for the worktree, PR number, or PR title"
echo " If numeric (e.g., 7113), treats as PR number and checkouts that PR"
echo " If no branch-name provided, also used as branch name"
echo " Will check if this matches any PR title and use that PR's branch"
echo " [branch-name]: Optional, specific branch name (defaults to <name>)"
echo " Will check if this matches any PR title and use that PR's branch"
echo " --open <app>: Optional, specify where to open (default: none)"
echo " Use any command/application that accepts a directory path"
echo ""
echo "PR Detection:"
echo " - PR Number: If <name> is numeric (e.g., 7113), directly checkouts that PR"
echo " Uses PR number as folder name and PR's branch for checkout"
echo " - When only <name> is provided: checks if any PR title contains <name>"
echo " If found, uses PR's branch name for both folder and branch"
echo " - When both arguments provided: checks if any PR title contains [branch-name]"
echo " If found, uses PR's branch but keeps specified folder name"
echo " - Use quotes for exact title matching: \"exact title\" vs partial matching"
echo " - If no matching PR is found, uses standard branch resolution"
echo ""
echo "Examples:"
echo " git-turnouts add 7113 # Checkouts PR #7113"
echo " git-turnouts add feature-x # Creates feature-x worktree"
echo " git-turnouts add JIRA-123 # Checks for PR with JIRA-123"
echo " git-turnouts add \"Exact PR Title\" # Exact title match"
echo " git-turnouts add folder-name branch-name # Different names"
echo " git-turnouts add feature-x --open <editor> # Opens in specified editor"
}
if [ -z "$1" ]; then
show_add_usage
exit 1
fi
# Parse arguments
local FOLDER_NAME=""
local BRANCH=""
local PARSING_OPEN=false
local EXTRA_ARGS=0
for arg in "$@"; do
if [ "$PARSING_OPEN" = true ]; then
OPEN_APP="$arg"
PARSING_OPEN=false
elif [ "$arg" = "--open" ] || [ "$arg" = "-o" ]; then
PARSING_OPEN=true
elif [ -z "$FOLDER_NAME" ]; then
FOLDER_NAME="$arg"
elif [ -z "$BRANCH" ]; then
BRANCH="$arg"
else
EXTRA_ARGS=$((EXTRA_ARGS + 1))
fi
done
# Validate argument count
if [ $EXTRA_ARGS -gt 0 ]; then
echo "❌ Error: Too many arguments provided"
echo "Expected: git-turnouts add <name> [branch-name] [--open <app>]"
echo ""
echo "You provided more than 2 positional arguments."
echo "If your argument contains spaces, please enclose it in quotes."
echo "Example: git-turnouts add \"My PR Title with spaces\""
echo ""
show_add_usage
exit 1
fi
# If no branch specified, use folder name
if [ -z "$BRANCH" ]; then
BRANCH="$FOLDER_NAME"
fi
# Check if we should use a PR branch
local PR_BRANCH=""
local ORIGINAL_FOLDER_NAME="$FOLDER_NAME"
if [ -z "$BRANCH" ] || [ "$BRANCH" = "$FOLDER_NAME" ]; then
# Case 1: Only <name> argument passed
# First check if it's a PR number
if is_pr_number "$FOLDER_NAME"; then
echo "🔢 Detected PR number: #$FOLDER_NAME"
echo "🔍 Fetching PR #$FOLDER_NAME details..."
# Get full PR details including state
PR_DETAILS=$(get_pr_details_by_number "$FOLDER_NAME")
if [ -z "$PR_DETAILS" ]; then
echo "❌ Error: PR #$FOLDER_NAME not found or not accessible"
exit 1
fi
PR_STATE=$(echo "$PR_DETAILS" | jq -r '.state')
PR_BRANCH=$(echo "$PR_DETAILS" | jq -r '.headRefName')
BASE_BRANCH=$(echo "$PR_DETAILS" | jq -r '.baseRefName')
if [ "$PR_STATE" = "MERGED" ]; then
echo "⚠️ PR #$FOLDER_NAME has been merged into $BASE_BRANCH"
echo ""
echo "The PR branch '$PR_BRANCH' may have been deleted from remote."
echo ""
echo "Options:"
echo " 1. Checkout the merge commit from $BASE_BRANCH"
echo " 2. If the branch still exists remotely, you can try: git fetch origin $PR_BRANCH"
echo " 3. Create a new feature branch from $BASE_BRANCH"
exit 1
elif [ "$PR_STATE" = "CLOSED" ]; then
echo "⚠️ PR #$FOLDER_NAME is closed (not merged)"
echo "Branch: $PR_BRANCH"
echo ""
echo "Note: The branch may still exist. Proceeding to attempt checkout..."
BRANCH="$PR_BRANCH"
else
echo "📋 Found PR #$FOLDER_NAME (state: $PR_STATE), using branch: $PR_BRANCH"
BRANCH="$PR_BRANCH"
fi
else
# Not a PR number, check for PR title matching
set +e # Temporarily disable exit on error
SEARCH_TERM=$(check_and_strip_quotes "$FOLDER_NAME")
WAS_QUOTED=$?
set -e # Re-enable exit on error
if [ $WAS_QUOTED -eq 0 ]; then
echo "🔍 Checking for PR with exact title '$SEARCH_TERM'..."
PR_BRANCH=$(get_pr_branch_by_title "$SEARCH_TERM" "true")
if [ -n "$PR_BRANCH" ]; then
echo "📋 Found PR with exact title '$SEARCH_TERM', using branch: $PR_BRANCH"
BRANCH="$PR_BRANCH"
FOLDER_NAME="${PR_BRANCH//\//-}"
else
echo "ℹ️ No PR found with exact title '$SEARCH_TERM', using standard branch resolution"
FOLDER_NAME="$SEARCH_TERM"
fi
else
echo "🔍 Checking for PR with title containing '$SEARCH_TERM'..."
PR_BRANCH=$(get_pr_branch_by_title "$SEARCH_TERM" "false")
if [ -n "$PR_BRANCH" ]; then