Created
April 15, 2022 17:30
-
-
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 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 | |
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