Repository navigation
Expand file tree
/
Copy pathprune-merged-branches.sh
More file actions
executable file
·177 lines (145 loc) · 5.54 KB
/
Copy pathprune-merged-branches.sh
File metadata and controls
executable file
·177 lines (145 loc) · 5.54 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
#!/usr/bin/env bash
#
# prune-merged-branches.sh - delete local branches whose commits all live on a remote.
#
# Exit codes:
# 0 done (or dry run printed)
# 1 a path was not a repo, or a delete failed
# 2 usage error
set -uo pipefail
usage() {
cat <<'HELP'
----------------------------------------
Dimitry4Now :: prune-merged-branches.sh
----------------------------------------
USAGE
prune-merged-branches.sh [OPTIONS] [REPO...]
OPTIONS
--apply actually delete (default is a dry run)
-f, --fetch run `git fetch --all --prune` first (may prompt for a passphrase)
-g, --gone-only only branches whose upstream is gone from the remote
-k, --keep RE never delete branches matching this regex (repeatable);
main, master, develop and dev are always protected
-h, --help show this help
REPO defaults to the current directory.
A branch is deleted ONLY when `git log <branch> --not --remotes` is empty,
i.e. every commit on it is already reachable from some remote-tracking ref.
Branches with even one unique commit are kept and reported. The current
branch, and any branch checked out in another worktree, are never touched.
DRY RUN BY DEFAULT - nothing is deleted until you pass --apply.
EXAMPLES
prune-merged-branches.sh ~/code/my-project # see what would go
prune-merged-branches.sh --fetch --apply ~/code/my-project
prune-merged-branches.sh --gone-only --apply .
HELP
}
die() {
printf '%s: %s\n' "${0##*/}" "$1" >&2
exit "${2:-2}"
}
# ---------------------------------------------------------------------------
# OPTIONS
# ---------------------------------------------------------------------------
apply=0
do_fetch=0
gone_only=0
keep_patterns=('^(main|master|develop|dev)$')
while [ $# -gt 0 ]; do
case "$1" in
--apply) apply=1 ;;
-f|--fetch) do_fetch=1 ;;
-g|--gone-only) gone_only=1 ;;
-k|--keep)
[ $# -ge 2 ] || die "--keep needs a regex"
keep_patterns+=("$2")
shift
;;
--keep=*) keep_patterns+=("${1#*=}") ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "unknown option: $1 (try --help)" ;;
*) break ;;
esac
shift
done
[ $# -gt 0 ] || set -- .
command -v git >/dev/null 2>&1 || die "git not found" 1
if [ -t 1 ]; then
RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; BOLD=$'\033[1m'; OFF=$'\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BOLD=''; OFF=''
fi
is_protected() {
local pattern
for pattern in "${keep_patterns[@]}"; do
[[ $1 =~ $pattern ]] && return 0
done
return 1
}
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
rc=0
for repo in "$@"; do
if ! git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then
printf '%snot a git repo:%s %s\n' "$RED" "$OFF" "$repo" >&2
rc=1
continue
fi
root=$(git -C "$repo" rev-parse --show-toplevel)
printf '%s%s%s\n' "$BOLD" "$root" "$OFF"
[ "$do_fetch" -eq 0 ] || git -C "$root" fetch --all --prune --quiet
if [ -z "$(git -C "$root" for-each-ref refs/remotes)" ]; then
printf ' %sno remote-tracking refs - refusing to prune (nothing to compare against)%s\n\n' \
"$RED" "$OFF"
continue
fi
current=$(git -C "$root" symbolic-ref --quiet --short HEAD || true)
# Branches checked out in any worktree, the main one included.
checked_out=$(git -C "$root" worktree list --porcelain \
| awk '/^branch /{sub("refs/heads/","",$2); print $2}')
deleted=0; kept=0; skipped=0
while read -r branch track; do
[ -n "$branch" ] || continue
if is_protected "$branch"; then
printf ' %sprotected%s %s\n' "$YELLOW" "$OFF" "$branch"
skipped=$((skipped + 1))
continue
fi
if [ "$branch" = "$current" ] || grep -qxF "$branch" <<<"$checked_out"; then
printf ' %schecked out%s %s\n' "$YELLOW" "$OFF" "$branch"
skipped=$((skipped + 1))
continue
fi
if [ "$gone_only" -eq 1 ] && [[ $track != *gone* ]]; then
skipped=$((skipped + 1))
continue
fi
unique=$(git -C "$root" rev-list --count "$branch" --not --remotes)
if [ "$unique" -ne 0 ]; then
printf ' %sKEEP%s %s - %s commit(s) on no remote\n' "$RED" "$OFF" "$branch" "$unique"
kept=$((kept + 1))
continue
fi
if [ "$apply" -eq 0 ]; then
printf ' %swould delete%s %s\n' "$GREEN" "$OFF" "$branch"
deleted=$((deleted + 1))
continue
fi
sha=$(git -C "$root" rev-parse --short "$branch")
if git -C "$root" branch -D "$branch" >/dev/null 2>&1; then
printf ' %sdeleted%s %s (was %s)\n' "$GREEN" "$OFF" "$branch" "$sha"
deleted=$((deleted + 1))
else
printf ' %sfailed%s %s\n' "$RED" "$OFF" "$branch"
rc=1
fi
done < <(git -C "$root" for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads)
if [ "$apply" -eq 1 ]; then
printf ' -> %d deleted, %d kept (unique commits), %d skipped\n\n' "$deleted" "$kept" "$skipped"
else
printf ' -> %d would be deleted, %d kept (unique commits), %d skipped\n' "$deleted" "$kept" "$skipped"
printf ' %sdry run - re-run with --apply to delete%s\n\n' "$YELLOW" "$OFF"
fi
done
exit "$rc"