Last active
June 7, 2024 23:08
-
-
Save philpennock/914001f5501535005a217a54df3c956c to your computer and use it in GitHub Desktop.
git delete branch just on, and push deletion upstream. Install in $PATH as `git-nuke-branch` and/or `git nb`; the nb spelling will auto-handle squashed commits
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
set -euo pipefail | |
# | |
# git nuke-branch / nb | |
# getopts defaults needed for usage text: | |
declare -i squashed_recent_commits=50 | |
USAGE="[-npsSu] [-r <recent>] <branch> [<branch> ...]" | |
LONG_USAGE="\ | |
Delete each specified branch locally, and then push the deletions to the remote | |
which is upstream of the current branch. Useful for removing feature-branches | |
locally and from forges after the code has landed in the trunk. | |
Accepts '-' as an alias for the previous branch. | |
If invoked as 'nb' then '-s' is default, use '-S' to override. | |
Options: | |
-n not really: don't actually delete or push deletions | |
-l local-only: don't actually push (so is just a merge-guard on local deletion) | |
-p push deletion even if we don't think the branch exists on remote | |
-r R look further back in history, R commits, for checking safety (def: $squashed_recent_commits) | |
-s squash commits: check for tree of branch in our recent history | |
-S no-squash commits | |
-u if upstream branch exists but remote main does not include branch as history, | |
delete the unmerged branch anyway | |
" | |
# Path coercion for platforms where git might be in multiple places and I can't | |
# mess with the ordering "normally" but want to explicitly pick up newer git | |
# here. | |
[[ -d /opt/local/bin ]] && PATH="/opt/local/bin:$PATH" | |
[[ -d /opt/git/bin ]] && PATH="/opt/git/bin:$PATH" | |
# shellcheck disable=SC2034 | |
SUBDIRECTORY_OK=true | |
set +eu | |
# shellcheck source=/dev/null | |
. "$(git --exec-path)/git-sh-setup" | |
set -eu | |
# I like prefices in front of messages, but I also like real exit codes | |
# So we could stomp on die, but in practice let's use die_n always, and | |
# force thoughtfulness. | |
progname="$(basename "$0" .sh)" | |
readonly progname | |
readonly ESC=$'\e' | |
if [ -t 1 ] && [ -z "${NO_COLOR:-${NOCOLOR:-}}" ]; then | |
readonly RunColorStart="${ESC}[32;3m" ErrorColorStart="${ESC}[31;1m" WarnColorStart="${ESC}[35;1m" ColorEnd="${ESC}[0m" | |
else | |
readonly RunColorStart='' ErrorColorStart='' WarnColorStart='' ColorEnd='' | |
fi | |
stderr() { printf >&2 '%s: %s\n' "$progname" "$*"; } | |
stderr_errorcolor() { printf >&2 '%s: %s%s%s\n' "$progname" "$ErrorColorStart" "$*" "$ColorEnd"; } | |
die_n() { local ev="$1"; shift; stderr_errorcolor "$@"; exit "$ev"; } | |
declare -ir EX_USAGE=64 EX_DATAERR=65 EX_UNAVAILABLE=69 EX_SOFTWARE=70 | |
declare -i failed=0 | |
fail() { stderr_errorcolor "failure:" "$@"; failed+=1; } | |
# See also up above the USAGE block, so we can refer to defaults in that text | |
declare -i accept_squashed=0 | |
declare -i not_really=0 | |
declare -i local_only=0 | |
declare -i push_deletion_always=0 | |
declare -i delete_upstream_unmerged=0 | |
declare -a push_args=() | |
if [[ "$progname" == "git-nb" ]]; then | |
accept_squashed=1 | |
fi | |
while getopts ':lnpr:sSu' arg; do | |
case "$arg" in | |
(l) local_only=1 ;; | |
(n) not_really=1 ;; | |
(p) push_deletion_always=1 ;; | |
(r) | |
# beware recursive expansion of variables in arithmetic context and the caller being able to specify an internal variable name as the value | |
[[ "$OPTARG" =~ ^[0-9]+$ ]] || die_n "$EX_USAGE" "need -r to be a number" ; | |
squashed_recent_commits="$OPTARG"; | |
(( squashed_recent_commits > 0 )) || die_n $EX_USAGE "need -r to be a number > 0" ;; | |
(s) accept_squashed=1 ;; | |
(S) accept_squashed=0 ;; | |
(u) delete_upstream_unmerged=1 ;; | |
(:) die_n $EX_USAGE "missing required option for -$OPTARG; see -h for help" ;; | |
(\?) die_n $EX_USAGE "unknown option -$OPTARG; see -h for help" ;; | |
(*) die_n $EX_SOFTWARE "unhandled option -$arg; CODE BUG" ;; | |
esac | |
done | |
shift $(( OPTIND - 1 )) | |
if (( not_really )); then | |
push_args+=( --dry-run ) | |
fi | |
cd_to_toplevel | |
(( $# )) || die_n $EX_USAGE "need at least one branch to nuke" | |
current_full_branch="$(git rev-parse --symbolic-full-name HEAD)" || die_n $EX_DATAERR "can't find HEAD branch" | |
if candidate="$(git config --local --get remotes.push)"; then | |
stderr "for propagating deletion, picking upstream '${candidate}' because is remotes.push" | |
remote_name="$candidate" | |
# I think it's reasonable that when nuking a branch we be on the branch which has a remote set, | |
# because we're likely to have just merged the branch. | |
# If there's no remotes, then might as well just branch -d. | |
elif ! remote_name="$(git for-each-ref --format='%(upstream:remotename)' "$current_full_branch")" || [[ -z "$remote_name" ]]; then | |
die_n $EX_DATAERR "current branch ${current_full_branch@Q} has no remote" | |
fi | |
unset candidate | |
readonly remote_name | |
if ! remote_head_branch="$(git rev-parse --symbolic-full-name "refs/remotes/${remote_name}/HEAD" 2>/dev/null)"; then | |
if (( not_really )); then | |
die_n $EX_UNAVAILABLE "need to mutate to set head, but -n set" | |
fi | |
git remote set-head -a "$remote_name" >&2 | |
remote_head_branch="$(git rev-parse --symbolic-full-name "refs/remotes/${remote_name}/HEAD")" | |
# let failure there be visible to caller | |
fi | |
remote_head_short_branch="${remote_head_branch#refs/remotes/${remote_name}/}" | |
remote_and_head_branch="${remote_name}/${remote_head_short_branch}" | |
stderr "current branch ${current_full_branch@Q} has upstream remote ${remote_name@Q} whose HEAD is ${remote_head_short_branch@Q}" | |
# TODO: decide if I want a -a flag for all-remotes, and have an associative | |
# array of remote names, each value of which is a string of deletions, and then | |
# for each branch being deleted, iterate over all remotes (`git remote`) and | |
# check for existence of that branch on that remote, and then push only | |
# deletion of those remote refs which we know of locally. | |
# | |
# For now, stick to "ensure primary (current branch's) upstream doesn't have any of these branches". | |
declare -a deletions=() | |
declare -i found=0 count_back=0 should_delete_remote=0 | |
if (( accept_squashed )); then | |
declare -a curbranch_recent_trees | |
curbranch_recent_trees=($( git log -n "$squashed_recent_commits" --pretty=tformat:%T )) | |
fi | |
for orig_branch_spec in "$@"; do | |
# can't use -- here: | |
case "$orig_branch_spec" in | |
(-) branch="@{-1}" ;; | |
(*) branch="$orig_branch_spec" ;; | |
esac | |
if ! full="$(git rev-parse --symbolic-full-name "$branch" 2>/dev/null)"; then | |
fail "can't find full branch name of ${branch@Q}, does it exist?" | |
continue | |
fi | |
if [[ "$full" == "$current_full_branch" ]]; then | |
fail "not nuking current branch ${branch@Q}" | |
continue | |
fi | |
case "$branch" in | |
(@*) branch="${full#refs/heads/}" ;; | |
esac | |
if [[ "$remote_head_short_branch" == "$branch" ]]; then | |
# This is our protection against deleting 'main' | |
fail "not nuking HEAD branch of remote ${remote_name@Q}" | |
continue | |
fi | |
# We always find the tree, I think it's useful to emit it as a diagnostic, always | |
was_commit="$(git rev-parse --verify "$branch")" | |
tree="$(git rev-parse --verify "${branch}^{tree}")" | |
if git rev-parse "refs/remotes/$remote_name/$branch" >/dev/null 2>&1; then | |
should_delete_remote=1 | |
else | |
should_delete_remote=0 | |
fi | |
if (( should_delete_remote )); then | |
if git merge-base --is-ancestor -- "$full" "$remote_head_branch"; then | |
# this has been merged into what we currently see as the remote's HEAD branch | |
stderr "branch ${branch@Q} exists on ${remote_name@Q} and has been merged into ${remote_and_head_branch@Q}" | |
elif (( delete_upstream_unmerged )); then | |
stderr "WARNING: ${branch@Q} exists on remote ${remote@Q}, HAS NOT BEEN MERGED INTO ${remote_and_head_branch@Q} but given -u are deleting anyway" | |
else | |
fail "branch ${branch@Q} exists on remote but has not been merged into ${remote_and_head_branch@Q} (or need to update remotes)" | |
continue | |
fi | |
fi | |
if (( accept_squashed )); then | |
found=0 | |
count_back=0 | |
for t in "${curbranch_recent_trees[@]}"; do | |
count_back+=1 | |
if [[ "$t" == "$tree" ]]; then | |
found=1 | |
break | |
fi | |
done | |
if (( ! found )); then | |
fail "branch ${branch@Q} tree ${tree} not found in most recent $squashed_recent_commits" | |
stderr "refusing to delete it, will not push deletion to ${remote_name@Q}" | |
continue | |
fi | |
if (( not_really )); then | |
stderr "would force-delete branch: ${branch@Q} [tree $tree is commit $count_back in history]" | |
elif ! git branch -D "$branch"; then | |
fail "force-deleting branch ${branch@Q} failed, not pushing deletion to ${remote_name@Q}" | |
continue | |
fi | |
elif (( not_really )); then | |
stderr "would delete branch: ${branch@Q}" | |
elif ! git branch -d "$branch"; then | |
fail "deleting branch ${branch@Q} failed, not pushing deletion to ${remote_name@Q}" | |
continue | |
fi | |
if (( count_back )); then | |
stderr "deleted branch ${branch@Q} [commit $was_commit][tree: $tree][history: $count_back]" | |
else | |
stderr "deleted branch ${branch@Q} [commit $was_commit][tree: $tree]" | |
fi | |
if (( should_delete_remote )); then | |
deletions+=(":$branch") | |
elif (( push_deletion_always )); then | |
stderr "don't see branch ${branch@Q} on remote ${remote_name@Q} but asked to push deletion anyway" | |
deletions+=(":$branch") | |
else | |
stderr "skipping branch deletion push because refs/remotes entry does not exist" | |
fi | |
done | |
if (( ! ${#deletions[@]} )); then | |
if (( failed )); then | |
die_n 1 "no deletions succeeded, so not pushing to remote [failures: $failed]" | |
else | |
stderr "no failures but nothing to push ... all done" | |
exit 0 | |
fi | |
fi | |
if (( local_only )); then | |
stderr "local-only requested, exiting without pushing ${deletions[*]}" | |
exit 0 | |
fi | |
stderr "deleting branches on remote ${remote_name@Q} ..." | |
if git push "${push_args[@]}" "$remote_name" "${deletions[@]}"; then | |
stderr "pushed deletions to ${remote_name@Q}: ${deletions[*]}" | |
else | |
failed+=1 | |
fi | |
if (( failed )); then | |
die_n 1 "encountered failures: $failed" | |
fi | |
exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment