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,28 +1,43 @@
1
1
  #!/usr/bin/env bash
2
- # shellcheck disable=SC2155
3
2
 
4
3
  # Source common utilities
5
- SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
4
+ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
5
  # shellcheck source=_utils.sh
7
6
  source "$SOURCE_DIR/_utils.sh"
8
7
 
8
+ # Returns 0 if all changes of the branch are in the base (merged, fast-forwarded or squash-merged)
9
+ _cleanup_is_merged() {
10
+ local branch="$1" base_ref="$2" merge_base squashed
11
+ git merge-base --is-ancestor "$branch" "$base_ref" 2>/dev/null && return 0
12
+ # Squash merge: a commit with the branch's combined changes already exists in the base
13
+ merge_base=$(git merge-base "$base_ref" "$branch" 2>/dev/null) || return 1
14
+ squashed=$(git commit-tree "$branch^{tree}" -p "$merge_base" -m "gitbash squash check" 2>/dev/null) || return 1
15
+ [[ "$(git cherry "$base_ref" "$squashed" 2>/dev/null)" == "-"* ]]
16
+ }
17
+
18
+ # Unix timestamp of N days ago
19
+ _cleanup_days_ago() {
20
+ local days="$1" ts
21
+ ts=$(date -v-"${days}d" +%s 2>/dev/null) ||
22
+ ts=$(date -d "${days} days ago" +%s 2>/dev/null) ||
23
+ ts=$(( $(date +%s) - days * 86400 ))
24
+ echo "$ts"
25
+ }
26
+
9
27
  # Cleanup local branches that are no longer needed
10
28
  cleanup() {
11
- # -----------------------------
12
- # 0. Check for help/version flag and parse options
13
- # -----------------------------
14
- local json_mode=false
15
- local dry_run=false
16
- local days_threshold="${GITBASH_CLEANUP_DAYS:-7}"
17
-
18
- while [[ $# -gt 0 ]]; do
19
- case "$1" in
20
- -v|--version)
21
- echo "gitbash ${FUNCNAME[0]} v$VERSION"
22
- return 0
23
- ;;
24
- -h|--help)
25
- cat << 'EOF'
29
+ local json_mode=false
30
+ local dry_run=false
31
+ local days_threshold="${GITBASH_CLEANUP_DAYS:-7}"
32
+
33
+ while [[ $# -gt 0 ]]; do
34
+ case "$1" in
35
+ -v|--version)
36
+ echo "gitbash ${FUNCNAME[0]} v$VERSION"
37
+ return 0
38
+ ;;
39
+ -h|--help)
40
+ cat << 'EOF'
26
41
  Usage: cleanup [OPTIONS]
27
42
 
28
43
  Find and delete local branches that are no longer needed.
@@ -30,484 +45,333 @@ Find and delete local branches that are no longer needed.
30
45
  Options:
31
46
  -h, --help Show this help message
32
47
  --json Output branch data as JSON (non-interactive)
33
- --dry-run Show what would be deleted without actually deleting
48
+ --dry-run List the branches that would be pre-selected, without deleting
34
49
  --days=N Override stale threshold (default: 7 days, configurable via GITBASH_CLEANUP_DAYS)
50
+ -y, --yes Delete the pre-selected branches without the picker (safe delete only)
35
51
 
36
- Categories (pre-selected for deletion):
37
- - Merged branches: Local branches whose remote was merged and deleted
38
- - Stale branches: Local branches with no commits in N+ days (default 7)
52
+ Labels:
53
+ [MERGED] All changes are in the base branch (merged, fast-forwarded or squash-merged).
54
+ Pre-selected when the remote branch is gone or it is older than N days.
55
+ [STALE] No commits in N+ days. Pre-selected only if everything is pushed.
56
+ [GONE] Remote branch was deleted, but the changes are NOT in the base branch.
57
+ [RECENT] Commits in the last N days.
58
+ "N unpushed" means N commits exist only on your machine.
39
59
 
40
- Not pre-selected (but listed):
41
- - Recent branches: Local branches with commits in the last N days
60
+ Protected branches (base branch and GITBASH_PROTECTED_BRANCHES) are never listed.
42
61
 
43
62
  Navigation:
44
- ↑/↓ or j/k Navigate through branches
63
+ ↑/↓ Navigate through branches
45
64
  TAB Select/deselect branch
46
65
  Enter Delete selected branch(es)
47
66
  ESC/Ctrl-C Exit without action
48
67
 
49
- Configuration:
50
- GITBASH_CLEANUP_DAYS - Set default days threshold (run 'gitbash --config')
51
-
52
- Notes:
53
- - Only deletes LOCAL branches (never touches remote)
54
- - If current branch is selected, switches to main/master first
55
- - Force deletes branches (even if not fully merged)
68
+ Deleting:
69
+ - Only deletes LOCAL branches (never touches the remote)
70
+ - If the current branch is selected, switches to the base branch first
71
+ - Uses 'git branch -d'. Branches with unmerged commits are listed and only
72
+ force-deleted after a separate confirmation (default: no).
56
73
 
57
74
  Examples:
58
75
  $ cleanup
59
- Local branches to clean up >
60
- > [MERGED] feature/old-feature 2 weeks ago
61
- [STALE] feature/abandoned 8 days ago
62
- [RECENT] feature/work-in-progress 2 days ago
63
- ✖ Abort
64
-
65
76
  $ cleanup --dry-run
66
- # Shows what would be deleted without deleting
67
-
68
77
  $ cleanup --days=14
69
- # Use 14-day threshold for stale branches
70
-
71
78
  $ cleanup --json
72
- [{"last_change_timestamp":1733123456,"author_email":"dev@example.com",...}]
73
79
 
74
80
  Requirements:
75
81
  - Must be in a git repository
76
- - fzf (fuzzy finder) - not required for --json mode
82
+ - fzf (not required for --json, --dry-run or --yes)
77
83
 
78
84
  EOF
79
- return 0
80
- ;;
81
- --json)
82
- json_mode=true
83
- shift
84
- ;;
85
- --dry-run)
86
- dry_run=true
87
- shift
88
- ;;
89
- --days=*)
90
- days_threshold="${1#--days=}"
91
- if ! [[ "$days_threshold" =~ ^[0-9]+$ ]] || [[ "$days_threshold" -lt 1 ]]; then
92
- print_error "Invalid days value: $days_threshold"
93
- return 1
94
- fi
95
- shift
96
- ;;
97
- *)
98
- echo "Unknown option: $1"
99
- return 1
100
- ;;
101
- esac
102
- done
103
- # -----------------------------
104
- # 1. Check prerequisites
105
- # -----------------------------
106
- if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
107
- if [[ "$json_mode" == true ]]; then
108
- echo "[]"
109
- else
110
- print_error "Not inside a git repository."
85
+ return 0
86
+ ;;
87
+ --json)
88
+ json_mode=true
89
+ shift
90
+ ;;
91
+ --dry-run)
92
+ dry_run=true
93
+ shift
94
+ ;;
95
+ --days=*)
96
+ days_threshold="${1#--days=}"
97
+ if ! [[ "$days_threshold" =~ ^[1-9][0-9]*$ ]]; then
98
+ print_error "Invalid days value: $days_threshold"
99
+ return 1
111
100
  fi
101
+ shift
102
+ ;;
103
+ -y|--yes)
104
+ GITBASH_ASSUME_YES=1
105
+ shift
106
+ ;;
107
+ *)
108
+ print_error "Unknown option: $1"
112
109
  return 1
113
- fi
114
-
115
- if [[ "$json_mode" == false ]]; then
116
- require_fzf || return 1
117
- fi
118
- # -----------------------------
119
- # 2. Fetch and prune to sync with remote
120
- # -----------------------------
121
- if ! git fetch --prune origin 2>/dev/null; then
122
- if [[ "$json_mode" == false ]]; then
123
- print_warning "Fetch failed; continuing with local data." >&2
124
- fi
125
- fi
126
-
127
- # -----------------------------
128
- # 3. Detect base branch
129
- # -----------------------------
130
- local base_branch
131
- if git show-ref --verify --quiet refs/heads/main; then
132
- base_branch="main"
133
- elif git show-ref --verify --quiet refs/heads/master; then
134
- base_branch="master"
110
+ ;;
111
+ esac
112
+ done
113
+
114
+ local interactive=true
115
+ if [[ "$json_mode" == true || "$dry_run" == true || "${GITBASH_ASSUME_YES:-}" == "1" ]]; then
116
+ interactive=false
117
+ fi
118
+
119
+ # -----------------------------
120
+ # 1. Prerequisites
121
+ # -----------------------------
122
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
123
+ if [[ "$json_mode" == true ]]; then echo "[]"; else print_error "Not inside a git repository."; fi
124
+ return 1
125
+ fi
126
+ if [[ "$interactive" == true ]]; then
127
+ require_fzf || return 1
128
+ fi
129
+
130
+ local remote
131
+ remote=$(gb_remote)
132
+ if ! git fetch --prune --quiet "$remote" 2>/dev/null; then
133
+ [[ "$json_mode" == false ]] && print_warning "Fetch failed; continuing with local data."
134
+ fi
135
+
136
+ local base_branch base_ref
137
+ if ! base_branch=$(gb_base_branch); then
138
+ if [[ "$json_mode" == true ]]; then echo "[]"; else print_error "Could not detect the base branch. Set GITBASH_BASE_BRANCH."; fi
139
+ return 1
140
+ fi
141
+ GB_BASE="$base_branch"
142
+ base_ref=$(gb_base_ref "$base_branch")
143
+
144
+ local current_branch
145
+ current_branch=$(gb_current_branch) || current_branch=""
146
+
147
+ # -----------------------------
148
+ # 2. Classify local branches
149
+ # -----------------------------
150
+ local threshold_ago
151
+ threshold_ago=$(_cleanup_days_ago "$days_threshold")
152
+
153
+ # Entry: preselect|label|timestamp|branch|relative_date|email|unpushed|name (name last: it may contain "|")
154
+ local entries=()
155
+ local sep=$'\x1f'
156
+ local branch ts rel email name _upstream track unpushed merged label preselect
157
+ while IFS="$sep" read -r branch ts rel email _upstream track name; do
158
+ [[ -z "$branch" ]] && continue
159
+ gb_is_protected "$branch" && continue
160
+
161
+ unpushed=$(git rev-list --count "refs/heads/$branch" --not --remotes 2>/dev/null || echo 0)
162
+ merged=false
163
+ _cleanup_is_merged "refs/heads/$branch" "$base_ref" && merged=true
164
+
165
+ preselect=0
166
+ if [[ "$merged" == true ]]; then
167
+ label="MERGED"
168
+ if [[ "$track" == "[gone]" || "$ts" -lt "$threshold_ago" ]]; then
169
+ preselect=1
170
+ fi
171
+ elif [[ "$track" == "[gone]" ]]; then
172
+ label="GONE"
173
+ elif [[ "$ts" -lt "$threshold_ago" ]]; then
174
+ label="STALE"
175
+ [[ "$unpushed" == "0" ]] && preselect=1
135
176
  else
136
- if [[ "$json_mode" == false ]]; then
137
- echo "Could not detect 'main' or 'master' locally."
138
- else
139
- echo "[]"
140
- fi
141
- return 1
142
- fi
143
-
144
- local current_branch
145
- current_branch=$(git rev-parse --abbrev-ref HEAD)
146
-
147
- # -----------------------------
148
- # 4. Build branch lists
149
- # -----------------------------
150
- local abort_label="✖ Abort"
151
- local threshold_ago
152
- threshold_ago=$(date -v-"${days_threshold}d" +%s 2>/dev/null || date -d "${days_threshold} days ago" +%s 2>/dev/null)
153
-
154
- # Get list of remote branches (without origin/ prefix)
155
- local remote_branches
156
- remote_branches=$(git for-each-ref --format='%(refname:short)' refs/remotes/origin | sed 's|^origin/||' | grep -v '^HEAD$' || true)
157
-
158
- # Arrays to hold branches by category (format: timestamp|branch|relative_date|author_email|author_name)
159
- local -a merged_branches
160
- local -a stale_branches
161
- local -a recent_branches
162
- merged_branches=()
163
- stale_branches=()
164
- recent_branches=()
165
-
166
- # Variables for loop (declared once to avoid zsh output on redeclaration)
167
- local has_remote configured_upstream entry
168
-
169
- # Process each local branch
170
- while IFS='|' read -r branch last_commit_ts relative_date author_email author_name; do
171
- # Skip base branch
172
- [[ "$branch" == "$base_branch" ]] && continue
173
- [[ -z "$branch" ]] && continue
174
-
175
- # Check if remote branch exists
176
- has_remote=false
177
- if echo "$remote_branches" | grep -qx "$branch"; then
178
- has_remote=true
179
- fi
180
-
181
- # Check if it HAD an upstream (was pushed before)
182
- configured_upstream=$(git config "branch.$branch.remote" 2>/dev/null)
183
-
184
- # Entry format: timestamp|branch|relative_date|author_email|author_name
185
- entry="$last_commit_ts|$branch|$relative_date|$author_email|$author_name"
186
-
187
- if [[ "$has_remote" == false && -n "$configured_upstream" ]]; then
188
- # Had upstream but remote is gone = merged and deleted remotely
189
- merged_branches+=("$entry")
190
- elif [[ -n "$last_commit_ts" && "$last_commit_ts" -lt "$threshold_ago" ]]; then
191
- # Stale: no commits in N+ days (pre-select for deletion)
192
- stale_branches+=("$entry")
193
- else
194
- # Recent: has recent activity (don't pre-select)
195
- recent_branches+=("$entry")
196
- fi
197
- done < <(git for-each-ref --format='%(refname:short)|%(committerdate:unix)|%(committerdate:relative)|%(committeremail)|%(committername)' refs/heads)
198
-
199
- # Sort each category by timestamp (most recent first), compatible with bash 3.x (no mapfile)
200
- _sort_desc() {
201
- local _var="$1"
202
- shift
203
- local _out=() _line
204
- while IFS= read -r _line; do
205
- _out+=("$_line")
206
- done < <(printf '%s\n' "$@" | sort -t'|' -k1 -rn)
207
- # shellcheck disable=SC2034 # assigned via eval to caller var
208
- eval "$_var=(\"\${_out[@]}\")"
209
- }
210
-
211
- if [[ ${merged_branches+set} && ${#merged_branches[@]} -gt 0 ]]; then
212
- _sort_desc merged_branches "${merged_branches[@]}"
213
- fi
214
- if [[ ${stale_branches+set} && ${#stale_branches[@]} -gt 0 ]]; then
215
- _sort_desc stale_branches "${stale_branches[@]}"
216
- fi
217
- if [[ ${recent_branches+set} && ${#recent_branches[@]} -gt 0 ]]; then
218
- _sort_desc recent_branches "${recent_branches[@]}"
219
- fi
220
-
221
- # -----------------------------
222
- # 5. JSON mode output
223
- # -----------------------------
224
- if [[ "$json_mode" == true ]]; then
225
- local json_output="["
226
- local first=true
227
-
228
- # Helper function to escape JSON strings
229
- _json_escape() {
230
- local str="$1"
231
- str="${str//\\/\\\\}"
232
- str="${str//\"/\\\"}"
233
- str="${str//$'\n'/\\n}"
234
- str="${str//$'\r'/\\r}"
235
- str="${str//$'\t'/\\t}"
236
- echo "$str"
237
- }
238
-
239
- # Process all branches for JSON output
240
- local all_entries=()
241
- if [[ ${merged_branches+set} ]]; then
242
- all_entries+=("${merged_branches[@]}")
243
- fi
244
- if [[ ${stale_branches+set} ]]; then
245
- all_entries+=("${stale_branches[@]}")
246
- fi
247
- if [[ ${recent_branches+set} ]]; then
248
- all_entries+=("${recent_branches[@]}")
249
- fi
250
-
251
- for entry in "${all_entries[@]}"; do
252
- [[ -z "$entry" ]] && continue
253
-
254
- local ts name rel_date email author
255
- ts=$(echo "$entry" | cut -d'|' -f1)
256
- name=$(echo "$entry" | cut -d'|' -f2)
257
- rel_date=$(echo "$entry" | cut -d'|' -f3)
258
- email=$(echo "$entry" | cut -d'|' -f4)
259
- author=$(echo "$entry" | cut -d'|' -f5)
260
-
261
- # Escape values for JSON
262
- name=$(_json_escape "$name")
263
- rel_date=$(_json_escape "$rel_date")
264
- email=$(_json_escape "$email")
265
- author=$(_json_escape "$author")
266
-
267
- if [[ "$first" == true ]]; then
268
- first=false
269
- else
270
- json_output+=","
271
- fi
272
-
273
- json_output+="{\"last_change_timestamp\":$ts,\"author_email\":\"$email\",\"author_name\":\"$author\",\"name\":\"$name\",\"last_change_relative\":\"$rel_date\"}"
274
- done
275
-
276
- json_output+="]"
277
- echo "$json_output"
278
- return 0
279
- fi
280
-
281
- # -----------------------------
282
- # 6. Build fzf input with pre-selection markers
283
- # -----------------------------
284
- local branch_list=""
285
- local preselect_list=""
286
- local idx=0
287
- local max_branch_len=60
288
-
289
- # Add merged branches (pre-selected)
290
- if [[ ${merged_branches+set} ]]; then
291
- for entry in "${merged_branches[@]}"; do
292
- local branch date display_branch line
293
- branch=$(echo "$entry" | cut -d'|' -f2)
294
- date=$(echo "$entry" | cut -d'|' -f3)
295
- display_branch="$branch"
296
- if [[ ${#display_branch} -gt $max_branch_len ]]; then
297
- display_branch="${display_branch:0:$((max_branch_len - 3))}..."
298
- fi
299
- line=$(printf "[MERGED] %-${max_branch_len}s %s\t%s" "$display_branch" "$date" "$branch")
300
- branch_list+="$line"$'\n'
301
- preselect_list+="$line"$'\n'
302
- ((idx++))
303
- done
177
+ label="RECENT"
304
178
  fi
305
-
306
- # Add stale branches (pre-selected)
307
- if [[ ${stale_branches+set} ]]; then
308
- for entry in "${stale_branches[@]}"; do
309
- local branch date display_branch line
310
- branch=$(echo "$entry" | cut -d'|' -f2)
311
- date=$(echo "$entry" | cut -d'|' -f3)
312
- display_branch="$branch"
313
- if [[ ${#display_branch} -gt $max_branch_len ]]; then
314
- display_branch="${display_branch:0:$((max_branch_len - 3))}..."
315
- fi
316
- line=$(printf "[STALE] %-${max_branch_len}s %s\t%s" "$display_branch" "$date" "$branch")
317
- branch_list+="$line"$'\n'
318
- preselect_list+="$line"$'\n'
319
- ((idx++))
320
- done
321
- fi
322
-
323
- # Add recent branches (not pre-selected)
324
- if [[ ${recent_branches+set} ]]; then
325
- for entry in "${recent_branches[@]}"; do
326
- local branch date display_branch line
327
- branch=$(echo "$entry" | cut -d'|' -f2)
328
- date=$(echo "$entry" | cut -d'|' -f3)
329
- display_branch="$branch"
330
- if [[ ${#display_branch} -gt $max_branch_len ]]; then
331
- display_branch="${display_branch:0:$((max_branch_len - 3))}..."
332
- fi
333
- line=$(printf "[RECENT] %-${max_branch_len}s %s\t%s" "$display_branch" "$date" "$branch")
334
- branch_list+="$line"$'\n'
335
- ((idx++))
336
- done
179
+ entries+=("$preselect|$label|$ts|$branch|$rel|$email|$unpushed|$name")
180
+ done < <(git for-each-ref \
181
+ --format="%(refname:short)%1f%(committerdate:unix)%1f%(committerdate:relative)%1f%(committeremail)%1f%(upstream:short)%1f%(upstream:track)%1f%(committername)" \
182
+ refs/heads 2>/dev/null)
183
+
184
+ # Pre-selected first, then newest first
185
+ local sorted=()
186
+ if [[ ${#entries[@]} -gt 0 ]]; then
187
+ while IFS= read -r line; do
188
+ sorted+=("$line")
189
+ done < <(printf '%s\n' "${entries[@]}" | sort -t'|' -k1,1rn -k3,3rn)
190
+ fi
191
+
192
+ # -----------------------------
193
+ # 3. JSON output
194
+ # -----------------------------
195
+ if [[ "$json_mode" == true ]]; then
196
+ local json="[" first=true entry
197
+ for entry in ${sorted[@]+"${sorted[@]}"}; do
198
+ IFS='|' read -r preselect label ts branch rel email unpushed name <<< "$entry"
199
+ email="${email#<}"
200
+ email="${email%>}"
201
+ [[ "$first" == true ]] && first=false || json+=","
202
+ json+="{\"last_change_timestamp\":$ts,\"author_email\":\"$(gb_json_escape "$email")\",\"author_name\":\"$(gb_json_escape "$name")\",\"name\":\"$(gb_json_escape "$branch")\",\"last_change_relative\":\"$(gb_json_escape "$rel")\",\"category\":\"$(echo "$label" | tr '[:upper:]' '[:lower:]')\",\"preselected\":$([[ "$preselect" == 1 ]] && echo true || echo false),\"unpushed_commits\":$unpushed}"
203
+ done
204
+ echo "$json]"
205
+ return 0
206
+ fi
207
+
208
+ if [[ ${#sorted[@]} -eq 0 ]]; then
209
+ echo "No local branches to clean up."
210
+ return 0
211
+ fi
212
+
213
+ # -----------------------------
214
+ # 4. Select branches
215
+ # -----------------------------
216
+ local selected=()
217
+ local preselect_count=0 entry
218
+ for entry in "${sorted[@]}"; do
219
+ [[ "${entry%%|*}" == "1" ]] && preselect_count=$((preselect_count + 1))
220
+ done
221
+
222
+ if [[ "$interactive" == false ]]; then
223
+ for entry in "${sorted[@]}"; do
224
+ IFS='|' read -r preselect label ts branch rel email unpushed name <<< "$entry"
225
+ [[ "$preselect" == "1" ]] && selected+=("$branch")
226
+ done
227
+ if [[ ${#selected[@]} -eq 0 ]]; then
228
+ echo "No branches to clean up (none pre-selected)."
229
+ return 0
337
230
  fi
338
-
339
- if [[ -z "$branch_list" ]]; then
340
- echo "No local branches to clean up."
341
- return 0
231
+ if [[ "$dry_run" == true ]]; then
232
+ echo "Would delete ${#selected[@]} branch(es):"
233
+ printf ' - %s\n' "${selected[@]}"
234
+ return 0
342
235
  fi
236
+ else
237
+ local abort_label="✖ Abort" list="" display note
238
+ local max_len=60
239
+ for entry in "${sorted[@]}"; do
240
+ IFS='|' read -r preselect label ts branch rel email unpushed name <<< "$entry"
241
+ display="$branch"
242
+ if [[ ${#display} -gt $max_len ]]; then
243
+ display="${display:0:$((max_len - 3))}..."
244
+ fi
245
+ note=""
246
+ [[ "$unpushed" != "0" ]] && note=" ($unpushed unpushed)"
247
+ [[ "$branch" == "$current_branch" ]] && note+=" (current)"
248
+ list+=$(printf "%-9s %-${max_len}s %s%s" "[$label]" "$display" "$rel" "$note")
249
+ list+=$'\t'"$branch"$'\n'
250
+ done
251
+ list+="$abort_label"$'\t'
343
252
 
344
- # Add abort option
345
- branch_list+="$abort_label"$'\t\t'
346
-
347
- # Create temp files
348
- local branch_file preselect_file
349
- branch_file=$(mktemp)
350
- preselect_file=$(mktemp)
351
- echo -e "$branch_list" > "$branch_file"
352
- echo -e "$preselect_list" > "$preselect_file"
353
-
354
- # -----------------------------
355
- # 7. Run fzf picker
356
- # -----------------------------
357
- local merged_count=0
358
- local stale_count=0
359
- local recent_count=0
360
-
361
- [[ ${merged_branches+set} ]] && merged_count=${#merged_branches[@]}
362
- [[ ${stale_branches+set} ]] && stale_count=${#stale_branches[@]}
363
- [[ ${recent_branches+set} ]] && recent_count=${#recent_branches[@]}
364
-
365
- local total_count=$((merged_count + stale_count + recent_count))
366
- local preselect_count=$((merged_count + stale_count))
367
-
368
- # Build toggle sequence for pre-selection (toggle first N items)
369
- local toggle_sequence="first"
370
- for ((i=0; i<preselect_count; i++)); do
371
- toggle_sequence+="+toggle+down"
253
+ # Toggle the first N (pre-selected) items once the list is loaded
254
+ local toggle_sequence="first" i
255
+ for ((i = 0; i < preselect_count; i++)); do
256
+ toggle_sequence+="+toggle+down"
372
257
  done
373
258
  toggle_sequence+="+first"
374
259
 
375
260
  local selection
376
- selection=$(fzf \
261
+ selection=$(run_fzf \
377
262
  --prompt="Local branches to clean up > " \
378
263
  -i \
379
264
  --reverse \
380
265
  --border \
381
266
  --header="[TAB] toggle | [Enter] delete selected | [ESC] exit
382
- Found $total_count branches ($preselect_count pre-selected for deletion)" \
267
+ ${#sorted[@]} branches, $preselect_count pre-selected" \
383
268
  --multi \
384
269
  --delimiter=$'\t' \
385
270
  --with-nth=1 \
386
- --bind=enter:accept \
387
271
  --preview='
388
- line={}
389
- if [[ "$line" == "✖ Abort"* ]]; then
390
- echo "Exit without action";
391
- else
392
- branch=$(echo "$line" | cut -f2)
393
- echo "Branch: $branch";
394
- echo "";
395
- echo "Recent commits:";
396
- git log --oneline --color=always -n 15 "$branch" 2>/dev/null || echo "No commits found";
272
+ branch=$(printf "%s" {} | cut -f2)
273
+ if [[ -z "$branch" ]]; then echo "Exit without action"; exit 0; fi
274
+ echo "Branch: $branch"
275
+ echo
276
+ unpushed=$(git log --oneline --color=always "refs/heads/$branch" --not --remotes 2>/dev/null)
277
+ if [[ -n "$unpushed" ]]; then
278
+ echo "Unpushed commits (only on this machine):"
279
+ echo "$unpushed"
280
+ echo
397
281
  fi
282
+ echo "Recent commits:"
283
+ git log --oneline --color=always -n 15 "refs/heads/$branch" 2>/dev/null
398
284
  ' \
399
- --preview-window=right:35% \
285
+ --preview-window=right:40% \
400
286
  --bind "load:$toggle_sequence" \
401
- < "$branch_file"
402
- ) </dev/tty || true
287
+ <<< "$list"
288
+ ) || true
403
289
 
404
- # Cleanup temp files
405
- rm -f "$branch_file" "$preselect_file"
406
-
407
- # ESC or Ctrl-C
408
290
  if [[ -z "$selection" ]]; then
409
- echo "Exited."
410
- return 0
411
- fi
412
-
413
- # Abort option
414
- if echo "$selection" | grep -q "^$abort_label"; then
415
- echo "Aborted."
416
- return 0
291
+ echo "Exited."
292
+ return 0
417
293
  fi
418
294
 
419
- # -----------------------------
420
- # 8. Extract branch names and confirm deletion
421
- # -----------------------------
422
- local branches_to_delete=()
423
- local need_switch=false
424
- local only_merged=true
425
- local branch_name line
426
-
295
+ local line
427
296
  while IFS= read -r line; do
428
- [[ "$line" == "✖ Abort"* ]] && continue
429
- branch_name=$(echo "$line" | cut -f2)
430
- if [[ -n "$branch_name" ]]; then
431
- branches_to_delete+=("$branch_name")
432
- if [[ "$branch_name" == "$current_branch" ]]; then
433
- need_switch=true
434
- fi
435
- # Check if this is not a MERGED branch
436
- if [[ ! "$line" == "[MERGED]"* ]]; then
437
- only_merged=false
438
- fi
439
- fi
297
+ branch=$(printf '%s' "$line" | cut -f2)
298
+ [[ -n "$branch" ]] && selected+=("$branch")
440
299
  done <<< "$selection"
441
300
 
442
- if [[ ${#branches_to_delete[@]} -eq 0 ]]; then
443
- echo "No branches selected."
444
- return 0
301
+ if [[ ${#selected[@]} -eq 0 ]]; then
302
+ echo "Aborted."
303
+ return 0
445
304
  fi
446
-
447
- echo ""
448
- echo "Selected local branches to delete:"
449
- for branch in "${branches_to_delete[@]}"; do
450
- if [[ "$branch" == "$current_branch" ]]; then
451
- echo " - $branch (current branch)"
452
- else
453
- echo " - $branch"
454
- fi
455
- done
456
-
457
- if [[ "$need_switch" == true ]]; then
458
- echo ""
459
- echo "Note: Will switch to '$base_branch' first (current branch selected for deletion)"
305
+ fi
306
+
307
+ # -----------------------------
308
+ # 5. Confirm and delete
309
+ # -----------------------------
310
+ local need_switch=false only_merged=true merged_list=" " label_of
311
+ for entry in "${sorted[@]}"; do
312
+ IFS='|' read -r preselect label ts branch rel email unpushed name <<< "$entry"
313
+ [[ "$label" == "MERGED" ]] && merged_list+="$branch "
314
+ done
315
+ echo "Selected local branches to delete:"
316
+ for branch in "${selected[@]}"; do
317
+ label_of=""
318
+ [[ "$branch" == "$current_branch" ]] && { need_switch=true; label_of=" (current branch)"; }
319
+ [[ "$merged_list" == *" $branch "* ]] || only_merged=false
320
+ echo " - $branch$label_of"
321
+ done
322
+ if [[ "$need_switch" == true ]]; then
323
+ echo "Will switch to '$base_branch' first."
324
+ fi
325
+
326
+ local default="n"
327
+ [[ "$only_merged" == true ]] && default="y"
328
+ if ! gb_confirm "Delete these ${#selected[@]} local branch(es)?" "$default"; then
329
+ echo "Deletion cancelled."
330
+ return 0
331
+ fi
332
+
333
+ if [[ "$need_switch" == true ]]; then
334
+ print_info "Switching to '$base_branch'..."
335
+ if ! git switch --quiet "$base_branch"; then
336
+ print_error "Failed to switch to '$base_branch'. Nothing was deleted."
337
+ return 1
460
338
  fi
461
- echo ""
462
-
463
- # Handle dry-run mode
464
- if [[ "$dry_run" == true ]]; then
465
- print_info "Dry run mode - no branches will be deleted"
466
- echo "Would delete ${#branches_to_delete[@]} branch(es):"
467
- for branch in "${branches_to_delete[@]}"; do
468
- echo " - $branch"
469
- done
470
- return 0
471
- fi
472
-
473
- # Default to Y if only merged branches, otherwise N
474
- local confirm
475
- if [[ "$only_merged" == true ]]; then
476
- prompt_read "Delete these ${#branches_to_delete[@]} local branch(es)? (Y/n): " confirm
477
- case "$confirm" in
478
- [nN][oO]|[nN])
479
- echo "Deletion cancelled."
480
- return 0
481
- ;;
482
- esac
339
+ fi
340
+
341
+ local refused=() out
342
+ for branch in "${selected[@]}"; do
343
+ if [[ "$merged_list" == *" $branch "* ]]; then
344
+ # Verified merged (including squash merges): git's own check may not see it
345
+ if git branch -D --quiet "$branch" >/dev/null 2>&1; then
346
+ print_success "Deleted $branch"
347
+ else
348
+ print_error "Failed to delete $branch"
349
+ fi
350
+ elif out=$(git branch -d "$branch" 2>&1); then
351
+ print_success "Deleted $branch"
483
352
  else
484
- prompt_read "Delete these ${#branches_to_delete[@]} local branch(es)? (y/N): " confirm
485
- case "$confirm" in
486
- [yY][eE][sS]|[yY])
487
- ;;
488
- *)
489
- echo "Deletion cancelled."
490
- return 0
491
- ;;
492
- esac
493
- fi
494
-
495
- # Switch to base branch if needed
496
- if [[ "$need_switch" == true ]]; then
497
- print_info "Switching to '$base_branch'..."
498
- git checkout "$base_branch" || {
499
- print_warning "Failed to switch to '$base_branch'. Aborting deletion."
500
- return 1
501
- }
353
+ refused+=("$branch")
502
354
  fi
503
-
504
- print_info "Deleting branches..."
505
- for branch in "${branches_to_delete[@]}"; do
506
- echo "Deleting $branch ..."
507
- if git branch -D "$branch" 2>/dev/null; then
508
- print_success "Deleted $branch"
355
+ done
356
+
357
+ if [[ ${#refused[@]} -gt 0 ]]; then
358
+ echo
359
+ print_warning "These branches have commits that are not merged. Deleting them loses those commits:"
360
+ for branch in "${refused[@]}"; do
361
+ echo " $branch:"
362
+ git log --oneline -n 5 "refs/heads/$branch" --not "$base_ref" --remotes 2>/dev/null | sed 's/^/ /'
363
+ done
364
+ if gb_confirm --strict "Force-delete these ${#refused[@]} branch(es)?" n; then
365
+ for branch in "${refused[@]}"; do
366
+ if git branch -D --quiet "$branch" >/dev/null 2>&1; then
367
+ print_success "Force-deleted $branch"
509
368
  else
510
- print_error "Failed to delete $branch"
369
+ print_error "Failed to delete $branch"
511
370
  fi
512
- done
371
+ done
372
+ else
373
+ echo "Kept: ${refused[*]}"
374
+ fi
375
+ fi
376
+ return 0
513
377
  }