gitbash 1.9.1 → 2.0.0

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.
@@ -1,344 +1,256 @@
1
1
  #!/usr/bin/env bash
2
- ## switch selects and switches git branches using fzf
3
- # shellcheck shell=bash
4
- # shellcheck disable=SC2155
5
2
 
6
3
  # Source common utilities
7
- SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
4
+ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
5
  # shellcheck source=_utils.sh
9
6
  source "$SOURCE_DIR/_utils.sh"
10
7
 
11
- # Select and switch to a branch using fzf
12
- switch() {
13
- # -----------------------------
14
- # 0. Check for help/version flag
15
- # -----------------------------
16
- if [[ "${1:-}" == "-v" || "${1:-}" == "--version" ]]; then
17
- echo "gitbash ${FUNCNAME[0]} v$VERSION"
8
+ # Print the branch list for fzf: "<label>: <name>\t<type>\t<name>"
9
+ _switch_list_branches() {
10
+ local current base="" base_ref merged="" name remote_name local_names
11
+ current=$(gb_current_branch) || current=""
12
+ if base=$(gb_base_branch 2>/dev/null); then
13
+ base_ref=$(gb_base_ref "$base")
14
+ merged=$(git for-each-ref --merged "$base_ref" --format='%(refname:short)' refs/heads 2>/dev/null)
15
+ fi
16
+
17
+ local_names=$(git for-each-ref --format='%(refname:short)' refs/heads)
18
+ while IFS= read -r name; do
19
+ [[ -z "$name" ]] && continue
20
+ if [[ "$name" != "$current" && "$name" != "$base" ]] && printf '%s\n' "$merged" | grep -qxF -- "$name"; then
21
+ printf 'merged: %s\tlocal\t%s\n' "$name" "$name"
22
+ else
23
+ printf 'local: %s\tlocal\t%s\n' "$name" "$name"
24
+ fi
25
+ done <<< "$local_names"
26
+
27
+ # Remote branches without a local branch of the same name
28
+ local printed_spacer=false
29
+ while IFS= read -r name; do
30
+ # Skip symbolic refs like "origin" (origin/HEAD)
31
+ [[ -z "$name" || "$name" != */* || "$name" == */HEAD ]] && continue
32
+ remote_name="${name#*/}"
33
+ printf '%s\n' "$local_names" | grep -qxF -- "$remote_name" && continue
34
+ if [[ "$printed_spacer" == false ]]; then
35
+ printf '─────────────────────────────\tspacer\t\n'
36
+ printed_spacer=true
37
+ fi
38
+ printf 'remote: %s\tremote\t%s\n' "$name" "$name"
39
+ done < <(git for-each-ref --format='%(refname:short)' refs/remotes)
40
+ }
41
+
42
+ # Delete the local branch of a list line (called from fzf via 'switch --delete-branch')
43
+ _switch_delete_branch() {
44
+ local line="$1" type name current count
45
+ type=$(printf '%s' "$line" | cut -f2)
46
+ name=$(printf '%s' "$line" | cut -f3)
47
+ current=$(gb_current_branch) || current=""
48
+
49
+ _switch_pause() { prompt_read "Press Enter to continue..." _ || true; }
50
+
51
+ if [[ "$type" != "local" ]]; then
52
+ echo "Only local branches can be deleted here. Use 'stale' to delete remote branches."
53
+ _switch_pause
54
+ return 0
55
+ fi
56
+ if [[ "$name" == "$current" ]]; then
57
+ print_warning "'$name' is the current branch - switch to another branch first."
58
+ _switch_pause
18
59
  return 0
19
60
  fi
20
- if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
21
- cat << 'EOF'
61
+ if gb_is_protected "$name"; then
62
+ print_warning "'$name' is protected (GITBASH_PROTECTED_BRANCHES)."
63
+ _switch_pause
64
+ return 0
65
+ fi
66
+
67
+ gb_confirm --strict "Delete local branch '$name'?" n || return 0
68
+ if git branch -d "$name" >/dev/null 2>&1; then
69
+ print_success "Deleted $name"
70
+ return 0
71
+ fi
72
+
73
+ count=$(git rev-list --count "refs/heads/$name" --not --remotes "$(gb_base_ref "$(gb_base_branch 2>/dev/null || echo HEAD)")" 2>/dev/null || echo "?")
74
+ print_warning "'$name' has $count commit(s) that are not merged or pushed:"
75
+ git log --oneline -n 10 "refs/heads/$name" --not --remotes 2>/dev/null | sed 's/^/ /'
76
+ if gb_confirm --strict "Force-delete '$name' and lose these commits?" n; then
77
+ if git branch -D "$name" >/dev/null 2>&1; then
78
+ print_success "Force-deleted $name"
79
+ else
80
+ print_error "Failed to delete $name"
81
+ _switch_pause
82
+ fi
83
+ fi
84
+ }
85
+
86
+ # Select and switch to a branch using fzf
87
+ switch() {
88
+ # Internal entry points used by fzf bindings
89
+ case "${1:-}" in
90
+ --list-branches)
91
+ _switch_list_branches
92
+ return 0
93
+ ;;
94
+ --delete-branch)
95
+ _switch_delete_branch "${2:-}"
96
+ return 0
97
+ ;;
98
+ -v|--version)
99
+ echo "gitbash ${FUNCNAME[0]} v$VERSION"
100
+ return 0
101
+ ;;
102
+ -h|--help)
103
+ cat << 'EOF'
22
104
  Usage: switch [FILTER...]
23
105
 
24
106
  Select a git branch using fzf and switch to it.
25
107
 
26
108
  Arguments:
27
- FILTER... Optional search filter words to pre-fill fzf (joined with spaces)
109
+ FILTER... Optional search words to pre-fill fzf (joined with spaces).
110
+ If exactly one branch name contains them, switches directly.
28
111
 
29
112
  Options:
30
113
  -h, --help Show this help message
31
114
 
32
115
  Behavior:
33
- - Lists both local and remote branches
34
- - Remote branches are hidden if a local branch with the same name exists
35
- - Uses fzf for interactive selection
36
- - Automatically switches to the selected branch
37
- - For remote branches, creates a local tracking branch if needed
38
- - Current branch is highlighted with an asterisk (*)
116
+ - Lists local branches ("merged:" = fully merged into the base branch)
117
+ and remote branches that have no local branch yet
118
+ - For remote branches, creates a local tracking branch
119
+ - After switching, offers to fast-forward if the branch is behind its upstream
120
+
121
+ Keys:
122
+ Enter Switch to the selected branch
123
+ Del Delete the selected local branch (asks first, default: no;
124
+ unmerged commits are shown and need a second confirmation)
125
+ Esc Exit
39
126
 
40
127
  Examples:
41
128
  $ switch
42
- # Opens fzf with list of branches, select one to switch
43
-
44
129
  $ switch captcha
45
- # Opens fzf with 'captcha' pre-filled as filter
46
-
47
130
  $ switch LOVE-123
48
- # Opens fzf filtering for branches containing 'LOVE-123'
49
-
50
- $ switch update figma guidelines
51
- # Opens fzf with 'update figma guidelines' pre-filled as filter
52
131
 
53
132
  Requirements:
54
133
  - Must be in a git repository
55
134
  - fzf must be installed
56
135
 
57
136
  EOF
58
- return 0
59
- fi
137
+ return 0
138
+ ;;
139
+ esac
60
140
 
61
- # -----------------------------
62
- # 1. Check prerequisites
63
- # -----------------------------
64
141
  require_git_repo || return 1
65
142
  require_fzf || return 1
66
143
 
67
- # -----------------------------
68
- # 2. Get current branch and filter
69
- # -----------------------------
70
- local current_branch
71
- current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
72
-
73
- local filter="$*"
144
+ local current_branch filter="$*"
145
+ current_branch=$(gb_current_branch) || current_branch="(detached HEAD)"
74
146
 
75
- # -----------------------------
76
- # 3. List branches and select with fzf
77
- # -----------------------------
78
- # Get local branches
79
- local local_branches
80
- local_branches=$(git branch --format='%(refname:short)')
81
- # Get merged local branches (merged into HEAD) in a portable way
82
- local merged_local_branches
83
- merged_local_branches=$(git branch --merged | sed 's/^..//' | sed '/^$/d' || true)
84
-
85
- # Get remote branches, filter out those that already exist locally
86
- local remote_branches
87
- remote_branches=$(git branch -r --format='%(refname:short)' | grep -v 'HEAD' | grep -v '^origin$' | while read -r remote; do
88
- local_name="${remote#*/}" # Remove origin/ prefix
89
- # Only include if no local branch with this name exists
90
- if ! echo "$local_branches" | grep -qx "$local_name"; then
91
- echo "$remote"
92
- fi
93
- done || true)
94
-
95
- local selected_branch
96
147
  local branch_list
97
- branch_list=$(
98
- {
99
- # List local branches, marking merged ones as 'merged:' (but keep current branch as local)
100
- echo "$local_branches" | while IFS= read -r lb; do
101
- # Skip empty lines
102
- [[ -z "$lb" ]] && continue
103
- if [[ "$lb" == "$current_branch" ]]; then
104
- echo "local: $lb"
105
- elif [[ "$lb" == "master" || "$lb" == "main" ]]; then
106
- # Keep main branches labelled as local
107
- echo "local: $lb"
108
- elif echo "$merged_local_branches" | grep -xq -- "${lb}" 2>/dev/null; then
109
- echo "merged: $lb"
110
- else
111
- echo "local: $lb"
112
- fi
113
- done
114
- # Spacer (only if there are remote branches to show)
115
- if [[ -n "$remote_branches" ]]; then
116
- echo "─────────────────────────────"
117
- # List filtered remote branches
118
- echo "$remote_branches" | sed 's/^/remote: /'
119
- fi
120
- }
121
- )
122
-
123
- # If no branches were found, bail out to avoid fzf listing files
124
- if [[ -z "$(echo "$branch_list" | sed -n '1p' || true)" ]]; then
148
+ branch_list=$(_switch_list_branches)
149
+ if [[ -z "$branch_list" ]]; then
125
150
  echo "No branches found in this repository."
126
151
  return 0
127
152
  fi
128
153
 
129
154
  # -----------------------------
130
- # 3b. Check for single match when filter is provided
155
+ # Single match: switch directly
131
156
  # -----------------------------
157
+ local selected="" query="$filter"
132
158
  if [[ -n "$filter" ]]; then
133
- # Filter branch list (case-insensitive), excluding spacer
134
- local matching_branches
135
- matching_branches=$(echo "$branch_list" | grep -iv '───' | grep -i "$filter" || true)
136
- local match_count
137
- match_count=$(echo "$matching_branches" | grep -c . 2>/dev/null || echo "0")
138
- # Ensure match_count is numeric
139
- [[ -z "$match_count" ]] && match_count=0
140
-
159
+ local matches match_count
160
+ matches=$(printf '%s\n' "$branch_list" | awk -F'\t' '$2 != "spacer"' |
161
+ while IFS= read -r line; do
162
+ printf '%s\n' "$line" | cut -f3 | grep -qiF -- "$filter" && printf '%s\n' "$line"
163
+ done)
164
+ match_count=$(printf '%s' "$matches" | grep -c . || true)
141
165
  if [[ "$match_count" == "1" ]]; then
142
- # Single match - skip fzf
143
- selected_branch="$matching_branches"
166
+ selected="$matches"
144
167
  print_info "Single match found, switching directly..."
145
- fi
146
- # If provided filter yields no matches, clear the query so fzf opens unfiltered
147
- if [[ "$match_count" == "0" ]]; then
148
- filter_for_fzf=""
149
- else
150
- filter_for_fzf="$filter"
168
+ elif [[ "$match_count" == "0" ]]; then
169
+ query=""
151
170
  fi
152
171
  fi
153
172
 
154
173
  # -----------------------------
155
- # 3c. Use fzf if no single match
174
+ # fzf picker
156
175
  # -----------------------------
157
- if [[ -z "$selected_branch" ]]; then
158
- # Write branch list to a temp file so fzf can reload after deletions
159
- branches_file=$(mktemp)
160
- printf '%s\n' "$branch_list" > "$branches_file"
161
-
162
- # Create a small helper script to delete the selected branch safely (asks for confirmation).
163
- delete_script=$(mktemp)
164
- cat > "$delete_script" <<'DEL_SH'
165
- #!/usr/bin/env bash
166
- line="$1"
167
- branches_file="$2"
168
-
169
- if [[ "$line" == *───* ]]; then
170
- echo "Spacer - not deletable"
171
- exit 0
172
- fi
173
-
174
- type="${line%%:*}"
175
- branch="${line#*: }"
176
-
177
- if [[ "$type" == "local" || "$type" == "merged" ]]; then
178
- # Ask for confirmation via the terminal (/dev/tty). Default answer is Yes when empty.
179
- while true; do
180
- printf "Delete local branch '%s'? [Y/n]: " "$branch" > /dev/tty
181
- read -r answer < /dev/tty
182
- case "$answer" in
183
- ''|[yY]|[yY][eE][sS])
184
- break
185
- ;;
186
- [nN]|[nN][oO])
187
- echo "Aborted deletion of $branch"
188
- exit 0
189
- ;;
190
- *)
191
- echo "Please answer y or n" > /dev/tty
192
- ;;
193
- esac
194
- done
195
-
196
- if git branch -D "$branch"; then
197
- echo "Deleted local $branch"
198
- awk -v l="$line" '$0!=l' "$branches_file" > "$branches_file".new && mv "$branches_file".new "$branches_file"
199
- else
200
- echo "Failed to delete $branch"
176
+ if [[ -z "$selected" ]]; then
177
+ local bin_q
178
+ printf -v bin_q '%q' "${GITBASH_BIN:-gitbash}"
179
+ selected=$(run_fzf \
180
+ --height=40% \
181
+ --reverse \
182
+ --border \
183
+ -i \
184
+ --query="$query" \
185
+ --delimiter=$'\t' \
186
+ --with-nth=1 \
187
+ --bind="del:execute($bin_q switch --delete-branch {} < /dev/tty > /dev/tty 2>&1)+reload($bin_q switch --list-branches)" \
188
+ --preview="
189
+ type=\$(printf '%s' {} | cut -f2); name=\$(printf '%s' {} | cut -f3)
190
+ case \"\$type\" in
191
+ local) ref=\"refs/heads/\$name\" ;;
192
+ remote) ref=\"refs/remotes/\$name\" ;;
193
+ *) echo 'Spacer - not selectable'; exit 0 ;;
194
+ esac
195
+ git log --color=always -n 1 --format='%C(bold cyan)Author:%C(reset) %an%n%C(bold cyan)Date:%C(reset) %ar (%ad)%n%C(bold cyan)Message:%C(reset) %s%n' --date=format:'%Y-%m-%d %H:%M' \"\$ref\" 2>/dev/null
196
+ echo
197
+ git log --oneline --color=always -n 10 \"\$ref\" 2>/dev/null
198
+ " \
199
+ --preview-window=right:50% \
200
+ --header="[Enter] switch | [Del] delete local branch | [Esc] exit | Current: $current_branch" \
201
+ <<< "$branch_list"
202
+ ) || true
201
203
  fi
202
- else
203
- echo "Remote branch deletion is disabled in this UI. Delete remote branches manually with: git push <remote> --delete <branch>"
204
- exit 0
205
- fi
206
- DEL_SH
207
- chmod +x "$delete_script"
208
-
209
- # Loop running fzf so we can handle delete keys via --expect and reload after deletion
210
- selected_branch=""
211
- while true; do
212
- fzf_out=$(
213
- fzf \
214
- --height=40% \
215
- --reverse \
216
- --border \
217
- --expect=enter,delete \
218
- --query="${filter_for_fzf:-$filter}" \
219
- -i \
220
- --preview="branch=\$(echo {} | sed 's/^[^:]*: //'); if [[ \"\$branch\" == *───* ]]; then echo 'Spacer - not selectable'; else git log --color=always -n 1 --format='%C(bold cyan)Author:%C(reset) %an%n%C(bold cyan)Date:%C(reset) %ar (%ad)%n%C(bold cyan)Message:%C(reset) %s%n' --date=format:'%Y-%m-%d %H:%M' \"\$branch\" 2>/dev/null && echo && git log --oneline --color=always -n 10 \"\$branch\" 2>/dev/null; fi" \
221
- --preview-window=right:50% \
222
- --header="[Enter] switch | [Del] delete (local only) | [Esc] exit | Current: $current_branch" \
223
- < "$branches_file"
224
- ) </dev/tty || true
225
-
226
- key_pressed=$(echo "$fzf_out" | head -n1)
227
- selection=$(echo "$fzf_out" | tail -n +2 | head -n1)
228
-
229
- # No selection or escape
230
- if [[ -z "$key_pressed" && -z "$selection" ]]; then
231
- selected_branch=""
232
- break
233
- fi
234
204
 
235
- if [[ "$key_pressed" == "enter" ]]; then
236
- selected_branch="$selection"
237
- break
238
- fi
239
-
240
- if [[ "$key_pressed" == "delete" ]]; then
241
- # perform deletion on the selected line and continue loop
242
- if [[ -n "$selection" ]]; then
243
- "$delete_script" "$selection" "$branches_file"
244
- fi
245
- continue
246
- fi
247
-
248
- # If there is a selection without a key, treat as accept
249
- if [[ -n "$selection" ]]; then
250
- selected_branch="$selection"
251
- break
252
- fi
253
- done
254
-
255
- # Clean up temp files
256
- rm -f "$delete_script" "$branches_file"
257
- fi
258
-
259
- # -----------------------------
260
- # 4. Handle selection
261
- # -----------------------------
262
- if [[ -z "$selected_branch" ]]; then
205
+ if [[ -z "$selected" ]]; then
263
206
  echo "No branch selected."
264
207
  return 0
265
208
  fi
266
209
 
267
- # Ignore spacer selection
268
- if [[ "$selected_branch" == *───* ]]; then
269
- echo "Invalid selection."
210
+ local branch_type branch_name
211
+ branch_type=$(printf '%s' "$selected" | cut -f2)
212
+ branch_name=$(printf '%s' "$selected" | cut -f3)
213
+ # Only accept well-formed list lines ("<label>\t<type>\t<name>")
214
+ if [[ ( "$branch_type" != "local" && "$branch_type" != "remote" ) || -z "$branch_name" ]]; then
215
+ echo "No branch selected."
270
216
  return 0
271
217
  fi
272
218
 
273
- # Extract branch type and name
274
- local branch_type="${selected_branch%%:*}"
275
- local branch_name="${selected_branch#*: }"
276
-
277
219
  # -----------------------------
278
- # 5. Switch to branch
220
+ # Switch
279
221
  # -----------------------------
280
- local switch_success=false
281
- if [[ "$branch_type" == "local" || "$branch_type" == "merged" ]]; then
282
- # Switch to local branch
283
- echo "Switching to local branch: $branch_name"
284
- if git checkout "$branch_name"; then
285
- switch_success=true
286
- fi
222
+ if [[ "$branch_type" == "local" ]]; then
223
+ git switch "$branch_name" || { print_error "Failed to switch branch."; return 1; }
287
224
  else
288
- # Handle remote branch
289
- local local_branch="${branch_name#*/}" # Remove origin/ prefix
290
-
291
- # Check if local branch already exists
225
+ local local_branch="${branch_name#*/}"
292
226
  if git show-ref --verify --quiet "refs/heads/$local_branch"; then
293
- echo "Switching to existing local branch: $local_branch"
294
- if git checkout "$local_branch"; then
295
- switch_success=true
296
- fi
227
+ git switch "$local_branch" || { print_error "Failed to switch branch."; return 1; }
297
228
  else
298
- echo "Creating local tracking branch: $local_branch (tracking $branch_name)"
299
- if git checkout -b "$local_branch" --track "$branch_name"; then
300
- switch_success=true
301
- fi
229
+ echo "Creating local branch '$local_branch' tracking '$branch_name'"
230
+ git switch -c "$local_branch" --track "$branch_name" || { print_error "Failed to switch branch."; return 1; }
302
231
  fi
303
232
  fi
233
+ print_success "Switched to branch: $(gb_current_branch)"
304
234
 
305
- if [[ "$switch_success" == true ]]; then
306
- print_success "Switched to branch: $(git rev-parse --abbrev-ref HEAD)"
307
-
308
- # -----------------------------
309
- # 6. Check if branch is behind upstream and offer to pull
310
- # -----------------------------
311
- local upstream behind_count
312
- upstream=$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || true)
313
-
314
- if [[ -n "$upstream" ]]; then
315
- # Fetch to get latest info (silently)
316
- git fetch origin 2>/dev/null || true
317
-
318
- # Check how many commits we're behind
319
- behind_count=$(git rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0")
320
-
321
- if [[ "$behind_count" -gt 0 ]]; then
322
- print_warning "Branch is $behind_count commit(s) behind '$upstream'"
323
- local pull_answer
324
- prompt_read "Pull latest changes? (Y/n): " pull_answer
325
- case "$pull_answer" in
326
- [nN][oO]|[nN])
327
- print_info "Skipped pulling. Run 'git pull' when ready."
328
- ;;
329
- *)
330
- if git pull; then
331
- print_success "Successfully pulled latest changes."
332
- else
333
- print_error "Pull failed. You may need to resolve conflicts."
334
- return 1
335
- fi
336
- ;;
337
- esac
235
+ # -----------------------------
236
+ # Offer to fast-forward if behind the upstream
237
+ # -----------------------------
238
+ local now upstream_remote upstream_merge behind
239
+ now=$(gb_current_branch) || return 0
240
+ upstream_remote=$(git config "branch.$now.remote" 2>/dev/null) || return 0
241
+ upstream_merge=$(git config "branch.$now.merge" 2>/dev/null) || return 0
242
+ git fetch --quiet "$upstream_remote" "$upstream_merge" 2>/dev/null || return 0
243
+ behind=$(git rev-list --count "HEAD..@{upstream}" 2>/dev/null) || return 0
244
+ if [[ "$behind" -gt 0 ]]; then
245
+ print_warning "Branch is $behind commit(s) behind its upstream."
246
+ if gb_confirm "Fast-forward to the latest changes?" y; then
247
+ if git merge --ff-only --quiet "@{upstream}"; then
248
+ print_success "Up to date."
249
+ else
250
+ print_error "Could not fast-forward (you have local commits or changes). Run 'git pull' to reconcile."
251
+ return 1
338
252
  fi
339
253
  fi
340
- else
341
- print_error "Failed to switch branch."
342
- return 1
343
254
  fi
255
+ return 0
344
256
  }