Skip to content

Instantly share code, notes, and snippets.

@philpennock
Last active June 7, 2024 23:08
Show Gist options
  • Save philpennock/914001f5501535005a217a54df3c956c to your computer and use it in GitHub Desktop.
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
#!/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