Skip to content

Instantly share code, notes, and snippets.

@philpennock
Created April 15, 2022 17:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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
USAGE="[-nsS] <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
-s squash commits: check for tree of branch in our recent history
-S no-squash commits
"
# 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, so we also stomp of the normal git die()
progname="$(basename "$0" .sh)"
stderr() { printf >&2 '%s: %s\n' "$progname" "$*"; }
die() { stderr "$@"; exit 1; }
declare -i accept_squashed=0
declare -i not_really=0
declare -i squashed_recent_commits=50
declare -a push_args=()
if [[ "$progname" == "git-nb" ]]; then
accept_squashed=1
fi
while getopts ':nsS' arg; do
case "$arg" in
(n) not_really=1 ;;
(s) accept_squashed=1 ;;
(S) accept_squashed=0 ;;
(:) die "missing required option for -$OPTARG; see -h for help" ;;
(\?) die "unknown option -$OPTARG; see -h for help" ;;
(*) die "unhandled option -$arg; CODE BUG" ;;
esac
done
shift $(( OPTIND - 1 ))
if (( not_really )); then
push_args+=( --dry-run )
fi
cd_to_toplevel
(( $# )) || die "need at least one branch to nuke"
declare -i failed=0
current_full_branch="$(git rev-parse --symbolic-full-name HEAD)" || die "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 "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 "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}/}"
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
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
stderr "can't find full branch name of ${branch@Q}, does it exist?"
failed+=1
continue
fi
if [[ "$full" == "$current_full_branch" ]]; then
stderr "not nuking current branch ${branch@Q}"
failed+=1
continue
fi
case "$branch" in
(@*) branch="${full#refs/heads/}" ;;
esac
if [[ "$remote_head_short_branch" == "$branch" ]]; then
# This is our protection against deleting 'main'
stderr "not nuking HEAD branch of remote ${remote_name@Q}"
failed+=1
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 (( 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
stderr "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}"
failed+=1
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
stderr "force-deleting branch ${branch@Q} failed, not pushing deletion to ${remote_name@Q}"
failed+=1
continue
fi
elif (( not_really )); then
stderr "would delete branch: ${branch@Q}"
elif ! git branch -d "$branch"; then
stderr "deleting branch ${branch@Q} failed, not pushing deletion to ${remote_name@Q}"
failed+=1
continue
fi
deletions+=(":$branch")
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
done
if (( ! ${#deletions[@]} )); then
die "nothing deleted, pushing nothing to remote [failures: $failed]"
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 "encountered failures: $failed"
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment