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.
- package/README.md +100 -89
- package/bin/gitbash +345 -723
- package/commands/_utils.sh +485 -157
- package/commands/branch.sh +9 -12
- package/commands/cleanstash.sh +47 -122
- package/commands/cleanup.sh +305 -441
- package/commands/commit.sh +136 -172
- package/commands/commits.sh +81 -114
- package/commands/create.sh +231 -314
- package/commands/pr.sh +158 -146
- package/commands/stale.sh +137 -277
- package/commands/stash.sh +11 -39
- package/commands/stashes.sh +10 -13
- package/commands/status.sh +197 -250
- package/commands/switch.sh +186 -274
- package/commands/unstash.sh +45 -109
- package/commands/update.sh +123 -142
- package/docs/CONTRIBUTING.md +16 -5
- package/docs/SECURITY.md +14 -3
- package/package.json +5 -3
package/commands/cleanup.sh
CHANGED
|
@@ -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]
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
↑/↓
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 (
|
|
82
|
+
- fzf (not required for --json, --dry-run or --yes)
|
|
77
83
|
|
|
78
84
|
EOF
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
#
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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=$(
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
if [[ "$
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
echo ""
|
|
395
|
-
echo "
|
|
396
|
-
|
|
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:
|
|
285
|
+
--preview-window=right:40% \
|
|
400
286
|
--bind "load:$toggle_sequence" \
|
|
401
|
-
|
|
402
|
-
)
|
|
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
|
-
|
|
410
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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 [[ ${#
|
|
443
|
-
|
|
444
|
-
|
|
301
|
+
if [[ ${#selected[@]} -eq 0 ]]; then
|
|
302
|
+
echo "Aborted."
|
|
303
|
+
return 0
|
|
445
304
|
fi
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
369
|
+
print_error "Failed to delete $branch"
|
|
511
370
|
fi
|
|
512
|
-
|
|
371
|
+
done
|
|
372
|
+
else
|
|
373
|
+
echo "Kept: ${refused[*]}"
|
|
374
|
+
fi
|
|
375
|
+
fi
|
|
376
|
+
return 0
|
|
513
377
|
}
|