From 43c153e8eb1e8c67ddab4e9682267b7f40b7f470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Fri, 16 Oct 2026 14:37:37 +0000 Subject: [PATCH] feat(git): add sign-branch and check-signed aliases --- .gitignore | 1 + bin/git-sign-branch | 74 +++++++++++++++++++++++++++++++++++++++++++++ git/config | 4 +++ zsh/.zshrc | 10 ++++++ 4 files changed, 89 insertions(+) create mode 100755 bin/git-sign-branch diff --git a/.gitignore b/.gitignore index 94b8be9..c61a793 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ sway/scripts/* !bin bin/* !bin/ssh-menu +!bin/git-sign-branch # swaync !swaync diff --git a/bin/git-sign-branch b/bin/git-sign-branch new file mode 100755 index 0000000..b8ac19c --- /dev/null +++ b/bin/git-sign-branch @@ -0,0 +1,74 @@ +#!/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" diff --git a/git/config b/git/config index 9e6ea7a..5d4e6d2 100644 --- a/git/config +++ b/git/config @@ -47,6 +47,10 @@ [alias] blame-ignore = "config blame.ignoreRevsFile .git-blame-ignore-revs" + # re-sign .. as the repo identity, keeping trees and merge topology + sign-branch = "!~/.config/bin/git-sign-branch --reset-author" + # list every commit in without a good signature; exits 1 if any, git's status if the range is bad + check-signed = "!f() { log=$(git log --format='%G? %h %s' \"${1:-main..HEAD}\") || exit; bad=$(printf '%s\\n' \"$log\" | grep -v -e '^[GU] ' -e '^$'); [ -z \"$bad\" ] && echo 'all signed' || { printf '%s\\n' \"$bad\"; exit 1; }; }; f" [branch] sort = -committerdate diff --git a/zsh/.zshrc b/zsh/.zshrc index 06005c8..6e08e73 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -91,6 +91,16 @@ fi zstyle ':completion:*' cache-path "$XDG_CACHE_HOME/zsh/zcompcache" zstyle ':completion:*' menu select zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}' +# _git picks these up for the matching git aliases (git/config) +_git-sign-branch() { + _arguments \ + '(-n --dry-run)'{-n,--dry-run}'[list the commits that would be signed]' \ + ':branch:__git_branch_names' \ + ':upstream:__git_references' +} +_git-check-signed() { + _arguments ':commit range:__git_commit_ranges' +} # tool hooks eval "$(ggman shellrc)"