#!/usr/bin/env bash # git sign-branch [-n] [--reset-author] [ []] # re-creates every commit in .. with a signature, keeping trees, parents (mapped to # the new ids), author, committer, dates and message byte for byte; merges are copied, never re-run # --reset-author: author and committer become this repo's user.name and user.email (the signer); dates stay # a branch with a merge of a signed tag (mergetag header) is refused, commit-tree can't carry the tag over set -euo pipefail dry=0 reset=0 while [[ ${1:-} == -* ]]; do case $1 in -n | --dry-run) dry=1 ;; --reset-author) reset=1 ;; *) echo "unknown option: $1" >&2; exit 1 ;; esac shift done if (( reset )); then me_name=$(git config user.name) me_email=$(git config user.email) fi branch=${1:-$(git symbolic-ref --short HEAD)} upstream=${2:-main} ref=refs/heads/$branch old_tip=$(git rev-parse --verify "$ref^{commit}") git rev-parse --verify -q "$upstream^{commit}" >/dev/null || { echo "no such upstream: $upstream" >&2; exit 1; } # checked before anything is signed, so a refusal leaves no partial work tagged=() while read -r c; do if [[ -n $(git cat-file commit "$c" | sed -n '1,/^$/{/^mergetag /p}') ]]; then tagged+=("$(git log -1 --format='%h %s' "$c")") fi done < <(git rev-list "$upstream..$old_tip") if (( ${#tagged[@]} )); then echo "refusing to sign $branch: commit-tree can't carry over the signed tag these merges embed (mergetag):" >&2 printf ' %s\n' "${tagged[@]}" >&2 exit 1 fi declare -A new # old id -> new id, only for commits in the range msg=$(mktemp); trap 'rm -f "$msg"' EXIT count=0 while read -r c parents; do args=() changed=0 for p in $parents; do np=${new[$p]:-$p}; [[ $np != "$p" ]] && changed=1 args+=(-p "$np") done # a commit already signed, with the same parents (and, with --reset-author, already ours) stays as it is mine=1 if (( reset )) && [[ $(git log -1 --format='%an%x1f%ae%x1f%cn%x1f%ce' "$c") != "$me_name"$'\x1f'"$me_email"$'\x1f'"$me_name"$'\x1f'"$me_email" ]]; then mine=0; fi if (( !changed && mine )) && git verify-commit "$c" >/dev/null 2>&1; then new[$c]=$c; continue; fi count=$((count + 1)) if (( dry )); then echo "would sign $(git log -1 --format='%h %s' "$c")"; new[$c]=dry-$c; continue fi git cat-file commit "$c" | sed '1,/^$/d' >"$msg" # message bytes after the header block # a non-utf-8 commit keeps its encoding header; names are read in it so their bytes stay too enc=$(git cat-file commit "$c" | sed -n '1,/^$/{s/^encoding //p}') enc=${enc:-UTF-8} IFS=$'\x1f' read -r an ae ad cn ce cd < <(git log -1 --encoding="$enc" --date=raw --format='%an%x1f%ae%x1f%ad%x1f%cn%x1f%ce%x1f%cd' "$c") if (( reset )); then an=$me_name ae=$me_email cn=$me_name ce=$me_email; fi new[$c]=$(GIT_AUTHOR_NAME=$an GIT_AUTHOR_EMAIL=$ae GIT_AUTHOR_DATE=$ad \ GIT_COMMITTER_NAME=$cn GIT_COMMITTER_EMAIL=$ce GIT_COMMITTER_DATE=$cd \ git -c i18n.commitEncoding="$enc" commit-tree -S "${args[@]}" -F "$msg" "$c^{tree}") done < <(git rev-list --reverse --topo-order --parents "$upstream..$old_tip") if (( dry )); then echo "$count commit(s) to sign on $branch"; exit 0; fi new_tip=${new[$old_tip]:-$old_tip} [[ $(git rev-parse "$new_tip^{tree}") == $(git rev-parse "$old_tip^{tree}") ]] || { echo "tip tree differs, ref left alone" >&2; exit 1; } # compare-and-swap: fails if the branch moved while we were signing git update-ref -m "sign-branch: signed $count commit(s)" "$ref" "$new_tip" "$old_tip" echo "$branch: $old_tip -> $new_tip ($count signed); undo: git update-ref $ref $old_tip"