Created
October 16, 2026 13:14
-
-
Save dzogrim/f07021c3c6f426a62c71b96301ffff8f to your computer and use it in GitHub Desktop.
clean up after a merged pull request
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 | |
| # | |
| # 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