Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Created October 16, 2026 13:14
Show Gist options
  • Select an option

  • Save dzogrim/f07021c3c6f426a62c71b96301ffff8f to your computer and use it in GitHub Desktop.

Select an option

Save dzogrim/f07021c3c6f426a62c71b96301ffff8f to your computer and use it in GitHub Desktop.
clean up after a merged pull request
#!/usr/bin/env bash
#
# git-done: clean up after a merged pull request.
#
# Fetches the upstream remote with prune, proves the current branch is merged,
# switches to the remote default branch, fast-forwards it, and deletes the
# branch. Every guardrail runs before the first state change, so an abort
# leaves the repository exactly as it was (apart from remote-tracking refs).
#
# Usage: git done [-n|--dry-run] [--no-gh] [-h|--help]
#
# Exit codes: 0 success, 1 guardrail abort or git failure, 2 usage error.
if [ -z "${BASH_VERSINFO:-}" ] || (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )); then
printf 'git-done: requires bash >= 4.4 (found %s)\n' "${BASH_VERSION:-unknown}" >&2
exit 1
fi
set -Eeuo pipefail
shopt -s inherit_errexit
# shellcheck disable=SC2034
readonly VERSION="1.0.0 (2026-10-16)"
readonly PROG='git-done'
readonly PROTECTED_RE='^(main|master|develop|trunk|release/.+)$'
DRY_RUN=0
USE_GH=1
usage() {
printf '%s\n' \
'Usage: git done [-n|--dry-run] [--no-gh] [-h|--help]' \
'' \
'After a PR merge: fetch with prune, prove the current branch is merged,' \
'switch to the remote default branch, fast-forward it, delete the branch.' \
'' \
' -n, --dry-run fetch and run every check; do not switch, merge or delete' \
' --no-gh skip the merged-PR check through gh (squash merges only)' \
' -h, --help show this help'
}
log() { printf '%s: %s\n' "$PROG" "$*" >&2; }
warn() { printf '%s: WARNING: %s\n' "$PROG" "$*" >&2; }
die() { printf '%s: ABORT: %s\n' "$PROG" "$*" >&2; exit 1; }
usage_error() {
printf '%s: %s\n' "$PROG" "$*" >&2
usage >&2
exit 2
}
on_err() {
local -r rc=$1 line=$2 cmd=$3
printf '%s: ERROR: exit %d at line %d: %s\n' "$PROG" "$rc" "$line" "$cmd" >&2
}
trap 'on_err "$?" "$LINENO" "$BASH_COMMAND"' ERR
# Hide credentials embedded in remote URLs (POSIX ERE: works with GNU and BSD sed).
redact() {
sed -E 's#([a-zA-Z][a-zA-Z0-9+.-]*://)[^@/[:space:]]+@#\1REDACTED@#g'
}
run() {
if (( DRY_RUN )); then
printf '[dry-run]' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
else
"$@"
fi
}
ref_exists() {
git show-ref --verify --quiet "$1"
}
parse_args() {
while (( $# )); do
case "$1" in
-n|--dry-run) DRY_RUN=1 ;;
--no-gh) USE_GH=0 ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) usage_error "unknown option: $1" ;;
*) usage_error "unexpected argument: $1" ;;
esac
shift
done
(( $# == 0 )) || usage_error "unexpected argument: $1"
}
resolve_default_branch() {
local -r remote=$1
local ref out
if ref="$(git symbolic-ref --quiet --short "refs/remotes/${remote}/HEAD" 2>/dev/null)"; then
printf '%s\n' "${ref#"${remote}/"}"
return 0
fi
out="$(git ls-remote --symref "$remote" HEAD 2>/dev/null)" \
|| die "cannot query '${remote}' for its default branch"
awk '$1 == "ref:" && $3 == "HEAD" && !found { sub("^refs/heads/", "", $2); print $2; found = 1 }' <<<"$out"
}
check_no_operation_in_progress() {
local marker path
for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG rebase-merge rebase-apply sequencer; do
path="$(git rev-parse --git-path "$marker")"
[[ ! -e "$path" ]] || die "operation in progress (${marker}); finish or abort it first"
done
}
confirm_merged_pr() {
local -r head=$1
local count
if (( ! USE_GH )); then
warn 'merged-PR check skipped (--no-gh); relying on push and prune evidence'
return 0
fi
if ! command -v gh >/dev/null 2>&1; then
warn 'gh not installed; relying on push and prune evidence'
return 0
fi
count="$(GH_PROMPT_DISABLED=1 gh pr list --head "$head" --state merged --limit 1 --json number --jq 'length' 2>/dev/null)" \
|| die "gh could not check PRs for '${head}' (auth or network); fix gh or rerun with --no-gh"
[[ "$count" =~ ^[0-9]+$ ]] || die "unexpected gh output: '${count}'"
(( count > 0 )) || die "remote branch '${head}' was deleted but has no merged PR (closed without merge?)"
log "gh confirms a merged PR for '${head}'"
}
main() {
parse_args "$@"
command -v git >/dev/null 2>&1 || die 'git not found in PATH'
local branch remote merge_ref remote_branch tracking_ref default default_ref
local status local_sha current_sha merged
local pushed=0
# Guardrail 1: a real branch inside a work tree.
[[ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" == 'true' ]] || die 'not inside a git work tree'
branch="$(git symbolic-ref --quiet --short HEAD)" || die 'detached HEAD; check out the merged branch first'
local_sha="$(git rev-parse --verify --quiet "refs/heads/${branch}^{commit}")" \
|| die "branch '${branch}' has no commits"
# Guardrail 2: the branch tracks a branch on a configured remote (names may differ).
remote="$(git config --get "branch.${branch}.remote")" || remote=''
merge_ref="$(git config --get "branch.${branch}.merge")" || merge_ref=''
[[ -n "$remote" && -n "$merge_ref" ]] || die "'${branch}' has no upstream; nothing proves it was pushed"
[[ "$remote" != '.' ]] || die "'${branch}' tracks a local branch, not a remote"
git remote get-url "$remote" >/dev/null 2>&1 || die "upstream remote '${remote}' is not a configured remote"
[[ "$merge_ref" == refs/heads/?* ]] || die "unexpected upstream ref '${merge_ref}'"
remote_branch="${merge_ref#refs/heads/}"
tracking_ref="refs/remotes/${remote}/${remote_branch}"
# Guardrail 3: a valid default branch, and never delete a protected branch.
default="$(resolve_default_branch "$remote")"
[[ -n "$default" ]] || die "cannot resolve the default branch of '${remote}'"
git check-ref-format --branch "$default" >/dev/null 2>&1 || die "invalid default branch name '${default}'"
if [[ "$branch" == "$default" || "$branch" =~ $PROTECTED_RE ]]; then
die "refusing to delete protected branch '${branch}'"
fi
# Guardrail 4: clean tree (untracked files and submodules included), nothing in progress.
status="$(git status --porcelain=v1 --untracked-files=normal --ignore-submodules=none)" \
|| die 'git status failed'
[[ -z "$status" ]] || die 'working tree not clean (commit, stash or remove untracked files)'
check_no_operation_in_progress
# Guardrail 5: before the fetch, prove every local commit is on the remote branch.
if ref_exists "$tracking_ref" && git merge-base --is-ancestor "$local_sha" "$tracking_ref"; then
pushed=1
fi
log "fetching '${remote}' with prune"
git fetch --prune --no-recurse-submodules "$remote" 2>&1 | redact >&2 \
|| die "fetch from '${remote}' failed"
default_ref="refs/remotes/${remote}/${default}"
ref_exists "$default_ref" || die "'${remote}/${default}' not found after fetch"
# Guardrail 6: prove the branch is merged.
if git merge-base --is-ancestor "$local_sha" "$default_ref"; then
merged='ancestor'
log "'${branch}' is fully contained in ${remote}/${default}"
elif (( pushed )) && ! ref_exists "$tracking_ref"; then
merged='squash'
log "'${branch}' was fully pushed and ${remote}/${remote_branch} is now deleted (squash or rebase merge)"
confirm_merged_pr "$remote_branch"
else
die "cannot prove '${branch}' is merged (remote branch still exists, or local commits were never pushed)"
fi
# Guardrail 7: a local default branch must fast-forward; check before switching.
if ref_exists "refs/heads/${default}" \
&& ! git merge-base --is-ancestor "refs/heads/${default}" "$default_ref"; then
die "local '${default}' has diverged from ${remote}/${default}; reconcile it manually"
fi
# Act: switch, fast-forward only, delete.
if ref_exists "refs/heads/${default}"; then
run git switch "$default"
else
run git switch --create "$default" --track "${remote}/${default}"
fi
run git merge --ff-only "$default_ref"
# Guardrail 8: the branch must not have moved since the checks ran.
current_sha="$(git rev-parse --verify --quiet "refs/heads/${branch}^{commit}")" \
|| die "branch '${branch}' disappeared during the run"
[[ "$current_sha" == "$local_sha" ]] || die "branch '${branch}' moved during the run; not deleting"
if [[ "$merged" == 'ancestor' ]]; then
run git branch -d "$branch"
else
run git branch -D "$branch"
fi
if (( DRY_RUN )); then
log 'dry run done; nothing switched, merged or deleted'
else
log "deleted '${branch}' (was ${local_sha:0:7}); restore with: git branch '${branch}' ${local_sha}"
fi
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment