From 16e37c779e25c777b4f57ab5f868029834a12b9e Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Mon, 28 Sep 2026 16:52:32 +0200 Subject: [PATCH 1/4] fix(scripts): keep the original author when a Co-authored-by already exists sign-branch.sh added the original author as a Co-authored-by trailer with `git interpret-trailers --if-exists doNothing`. --if-exists matches on the trailer key alone, so any existing Co-authored-by (an agent's, for example) made it skip the new one and the original author was dropped from the rebuilt commit. addIfDifferent still skips an exact duplicate. (cherry picked from commit 5b673ec0deccc385f639c4041bd2d08e873b4f8c) --- scripts/sign-branch.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/sign-branch.sh b/scripts/sign-branch.sh index 11549ab..ce96e31 100755 --- a/scripts/sign-branch.sh +++ b/scripts/sign-branch.sh @@ -185,9 +185,11 @@ while read -r sha; do trailer=(--trailer "Co-authored-by: $(git log -1 --format='%an' "$sha") <$orig_email>") fi # ${arr[@]+...} so an empty array is not an unbound-variable error under bash 3.2, - # which is still what macOS ships as /bin/bash. + # which is still what macOS ships as /bin/bash. addIfDifferent, not doNothing: + # --if-exists matches on the key alone, so doNothing dropped the original author + # whenever the message already had any Co-authored-by (an agent's, say). msg=$(git log -1 --format='%B' "$sha" \ - | git interpret-trailers --if-exists doNothing ${trailer[@]+"${trailer[@]}"}) + | git interpret-trailers --if-exists addIfDifferent ${trailer[@]+"${trailer[@]}"}) new=$( export GIT_AUTHOR_NAME=$me_name GIT_AUTHOR_EMAIL=$me_email export GIT_AUTHOR_DATE=$(git log -1 --format='%aI' "$sha") From 1800c1dfd4a49e373031701291703a56c9ed3c43 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Mon, 28 Sep 2026 16:59:48 +0200 Subject: [PATCH 2/4] feat(scripts): let sign-branch.sh sign a pull request, fork branches included sign-branch.sh only looked at origin, so it could not sign a PR whose branch lives on a contributor's fork. It now also takes a PR number, #number or PR URL (needs gh): the head and base are resolved from the PR and fetched and pushed by URL, in origin's protocol, which works for a fork when the PR allows maintainer edits. --pr with no number picks from the ten most recently updated open PRs, the way no branch picks from recent branches. Everything after locating the branch is unchanged, except the push lease now names the full ref, since a URL has no remote-tracking ref for a short name. --- scripts/sign-branch.sh | 154 +++++++++++++++++++++++++++++------------ 1 file changed, 111 insertions(+), 43 deletions(-) diff --git a/scripts/sign-branch.sh b/scripts/sign-branch.sh index ce96e31..8a83261 100755 --- a/scripts/sign-branch.sh +++ b/scripts/sign-branch.sh @@ -2,9 +2,21 @@ # sign-branch — take over a branch's commits as their author, signed with YOUR key. # # scripts/sign-branch.sh [] [--yes] [--no-push] +# scripts/sign-branch.sh [--yes] [--no-push] +# scripts/sign-branch.sh --pr [--yes] [--no-push] # # With no branch, the ten most recently pushed branches on origin are listed and one is -# picked with the arrow keys (or j/k, a digit, Enter; q to quit). +# picked with the arrow keys (or j/k, a digit, Enter; q to quit). With --pr and no +# number, the ten most recently updated open pull requests are listed instead. +# +# A pull request (a number, `#123`, or its URL; needs the `gh` CLI) is signed wherever its +# branch lives, a contributor's fork included: its head and base are fetched and pushed by +# URL, which works for a fork when the PR allows maintainer edits. A plain branch name is +# looked up on origin. +# +# As git aliases, from any repo: +# git config --global alias.sign-branch '!/scripts/sign-branch.sh' +# git config --global alias.sign-pr '!/scripts/sign-branch.sh --pr' # # The rocicorp org requires every commit to be SSH-signed by a key in this repo's # `.github/signing/allowed_signers`; the org-wide required workflow @@ -24,9 +36,9 @@ # bare or blobless clone. # # Only the named branch's OWN commits are touched: the range is everything it added since -# it forked from origin's default branch, so shared history can never be rewritten. That -# fork point is derived (`ls-remote --symref` for the default branch, then `merge-base`), -# never asked for. +# it forked from origin's default branch (a PR's base branch), so shared history can never +# be rewritten. That fork point is derived (`ls-remote --symref` for the default branch, +# or the PR's base, then `merge-base`), never asked for. # # Within that range it starts at the FIRST commit that needs rebuilding — one whose author # is not you, or that carries no signature — and runs to the tip, so commits already @@ -39,18 +51,26 @@ self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") die() { echo "sign-branch: $*" >&2; exit 1; } usage() { awk 'NR > 1 && !/^#/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "$self"; } -branch=; assume_yes=0; push=1 +target=; pr_mode=0; assume_yes=0; push=1 while [ $# -gt 0 ]; do case "$1" in + --pr) pr_mode=1; shift ;; --yes|-y) assume_yes=1; shift ;; --no-push) push=0; shift ;; -h|--help) usage; exit 0 ;; -*) die "unknown flag: $1" ;; - *) [ -z "$branch" ] || die "only one branch, got '$branch' and '$1'" - branch=$1; shift ;; + *) [ -z "$target" ] || die "only one branch or PR, got '$target' and '$1'" + target=$1; shift ;; esac done +# A number, #number or pull request URL names a PR; anything else is a branch on origin. +case "$target" in + https://*/pull/[0-9]*) pr_mode=1 ;; + '' | '#' | *[!0-9#]* | ?*'#'*) ;; + *) pr_mode=1; target=${target#\#} ;; +esac git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository" +[ "$pr_mode" = 0 ] || command -v gh >/dev/null || die "signing a pull request needs the gh CLI" # Fail before building anything if signing cannot produce what the org accepts. me_name=$(git config user.name) || die "no user.name configured" @@ -61,11 +81,6 @@ git config --get user.signingkey >/dev/null \ || die "gpg.format is not 'ssh' — allowed_signers only accepts SSH signatures: git config --global gpg.format ssh" -# Ask the remote what its default branch is, rather than reading a local ref: this works -# in a bare or fresh clone that has no refs/remotes/* at all, and cannot go stale. -default=$(git ls-remote --symref origin HEAD | sed -n 's|^ref: refs/heads/||p' | awk '{print $1}') -[ -n "$default" ] || die "cannot determine origin's default branch" - # Arrow-key menu on the terminal: sets $picked to the chosen index. Up/Down or k/j move, # a digit jumps, Enter picks, q or Esc aborts. Redraws in place with plain escapes so it # needs nothing beyond bash 3.2 and a VT100-ish terminal. @@ -97,40 +112,91 @@ menu() { picked=$cur } -# No branch named: offer the most recently pushed branches on origin. Fetched into -# refs/remotes/origin/* explicitly so this also works in a bare clone, which has none. recent=10 -if [ -z "$branch" ]; then - [ -t 0 ] && [ -t 1 ] || die "no branch given, and no terminal to pick one from" - echo "fetching branches from origin…" - git fetch --quiet --prune origin '+refs/heads/*:refs/remotes/origin/*' \ - || die "cannot fetch branches from origin" - names=(); labels=() - while IFS=$'\t' read -r name date author subject; do - case "$name" in "$default" | HEAD) continue ;; esac - names+=("$name") - labels+=("$(printf '%-36.36s %-14.14s %-16.16s %.40s' "$name" "$date" "$author" "$subject")") - [ "${#names[@]}" -lt "$recent" ] || break - done < <(git for-each-ref --sort=-committerdate \ - --format='%(refname:lstrip=3)%09%(committerdate:relative)%09%(authorname)%09%(contents:subject)' \ - refs/remotes/origin/) - [ "${#names[@]}" -gt 0 ] || die "origin has no branches other than $default" - echo - echo "which branch to sign? (${#names[@]} most recently pushed; tip author shown)" - menu "${labels[@]}" - branch=${names[$picked]} - echo -fi +branch= + +if [ "$pr_mode" = 1 ]; then + # --pr with no number: offer the most recently updated open pull requests. + if [ -z "$target" ]; then + [ -t 0 ] && [ -t 1 ] || die "no pull request given, and no terminal to pick one from" + echo "fetching open pull requests…" + nums=(); labels=() + while IFS=$'\t' read -r num author name title; do + nums+=("$num") + labels+=("$(printf '#%-6s %-16.16s %-30.30s %.40s' "$num" "$author" "$name" "$title")") + done < <(gh pr list --state open --limit "$recent" --search 'sort:updated-desc' \ + --json number,author,headRefName,title \ + --jq '.[] | [.number, .author.login, .headRefName, .title] | @tsv') + [ "${#nums[@]}" -gt 0 ] || die "no open pull requests" + echo + echo "which pull request to sign? (${#nums[@]} most recently updated)" + menu "${labels[@]}" + target=${nums[$picked]} + echo + fi -[ "$branch" != "$default" ] || die "$branch IS origin's default branch — refusing to rewrite it" + IFS=$'\t' read -r branch default head_repo base_repo state cross can_modify < <( + gh pr view "$target" \ + --json headRefName,baseRefName,headRepositoryOwner,headRepository,url,state,isCrossRepository,maintainerCanModify \ + --jq '[.headRefName, .baseRefName, .headRepositoryOwner.login + "/" + .headRepository.name, + (.url | capture("github.com/(?[^/]+/[^/]+)/pull").r), + .state, .isCrossRepository, .maintainerCanModify] | @tsv' + ) || die "cannot read pull request $target" + [ "$state" = OPEN ] || die "pull request $target is $state" + [ "$cross" != true ] || [ "$can_modify" = true ] \ + || die "pull request $target is from a fork that does not allow maintainer edits" + [ "$cross" = true ] || [ "$branch" != "$default" ] \ + || die "$branch IS the pull request's base branch — refusing to rewrite it" + + # Head and base by URL rather than a remote, since a fork's head usually has none; in + # origin's protocol, so whatever credentials origin uses apply. + case "$(git remote get-url origin 2>/dev/null || true)" in + https://*) repo_url() { echo "https://github.com/$1.git"; } ;; + *) repo_url() { echo "git@github.com:$1.git"; } ;; + esac + src=$(repo_url "$head_repo"); base_src=$(repo_url "$base_repo"); where=$head_repo +else + src=origin; base_src=origin; where=origin + # Ask the remote what its default branch is, rather than reading a local ref: this + # works in a bare or fresh clone that has no refs/remotes/* at all, and cannot go stale. + default=$(git ls-remote --symref origin HEAD | sed -n 's|^ref: refs/heads/||p' | awk '{print $1}') + [ -n "$default" ] || die "cannot determine origin's default branch" + branch=$target + + # No branch named: offer the most recently pushed branches on origin. Fetched into + # refs/remotes/origin/* explicitly so this also works in a bare clone, which has none. + if [ -z "$branch" ]; then + [ -t 0 ] && [ -t 1 ] || die "no branch given, and no terminal to pick one from" + echo "fetching branches from origin…" + git fetch --quiet --prune origin '+refs/heads/*:refs/remotes/origin/*' \ + || die "cannot fetch branches from origin" + names=(); labels=() + while IFS=$'\t' read -r name date author subject; do + case "$name" in "$default" | HEAD) continue ;; esac + names+=("$name") + labels+=("$(printf '%-36.36s %-14.14s %-16.16s %.40s' "$name" "$date" "$author" "$subject")") + [ "${#names[@]}" -lt "$recent" ] || break + done < <(git for-each-ref --sort=-committerdate \ + --format='%(refname:lstrip=3)%09%(committerdate:relative)%09%(authorname)%09%(contents:subject)' \ + refs/remotes/origin/) + [ "${#names[@]}" -gt 0 ] || die "origin has no branches other than $default" + echo + echo "which branch to sign? (${#names[@]} most recently pushed; tip author shown)" + menu "${labels[@]}" + branch=${names[$picked]} + echo + fi + + [ "$branch" != "$default" ] || die "$branch IS origin's default branch — refusing to rewrite it" +fi -echo "fetching origin…" +echo "fetching $where…" # FETCH_HEAD, not origin/: a bare clone has no remote-tracking refs, and it is # precisely the SHA just fetched — which is what the push lease below must pin. Fetch the # default branch first, since each fetch overwrites FETCH_HEAD. -git fetch --quiet origin "$default" || die "cannot fetch origin/$default" +git fetch --quiet "$base_src" "$default" || die "cannot fetch $default" default_sha=$(git rev-parse FETCH_HEAD^{commit}) -git fetch --quiet origin "$branch" || die "no branch '$branch' on origin" +git fetch --quiet "$src" "$branch" || die "no branch '$branch' on $where" head_sha=$(git rev-parse FETCH_HEAD^{commit}) # The fork point, so only what this branch added is ever in scope. @@ -156,7 +222,7 @@ done < <(git rev-list --reverse "$base..$head_sha") upstream=$(git rev-parse "$first_bad^") echo -echo "branch: $branch forked from: $default author: $me_name <$me_email>" +echo "branch: $where:$branch forked from: $default author: $me_name <$me_email>" if [ "$(git rev-list --count "$base..$upstream")" -gt 0 ]; then echo "keeping untouched:" git log --reverse --format=' %h %an %s' "$base..$upstream" @@ -166,7 +232,7 @@ git log --reverse --format=' %h %an %s' "$upstream..$head_sha" echo if [ "$assume_yes" != 1 ]; then - printf 'rebuild and force-push to origin/%s? [y/N] ' "$branch" + printf 'rebuild and force-push to %s:%s? [y/N] ' "$where" "$branch" read -r reply case "$reply" in y | Y | yes | YES) ;; *) die "aborted" ;; esac fi @@ -203,13 +269,15 @@ done < <(git rev-list --reverse "$upstream..$head_sha") staging=refs/sign-branch/$branch git update-ref "$staging" "$new" +# The lease names the full ref: with a URL rather than a remote there is no +# remote-tracking ref for a short name to resolve against. if [ "$push" != 1 ]; then echo "not pushed (--no-push) — rebuilt chain at $staging ($(git rev-parse --short "$new"))" echo " inspect: git log $base..$staging" - echo " push: git push --force-with-lease=$branch:$head_sha origin $staging:refs/heads/$branch" + echo " push: git push --force-with-lease=refs/heads/$branch:$head_sha $src $staging:refs/heads/$branch" exit 0 fi -git push --force-with-lease="$branch:$head_sha" origin "$new:refs/heads/$branch" +git push --force-with-lease="refs/heads/$branch:$head_sha" "$src" "$new:refs/heads/$branch" git update-ref -d "$staging" echo "pushed — your local $branch is now behind; git fetch when you next need it" From 11def62ff13df2b360ebc91298f750fa6f2836c7 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Mon, 28 Sep 2026 17:09:35 +0200 Subject: [PATCH 3/4] fix(scripts): harden sign-branch.sh PR mode - Refuse a same-repo PR whose head is that repo's default branch (main into a release branch, say): branch mode already refused origin's default, but PR mode only compared the head to the base, which never matches. - --branch forces branch mode, for a branch whose name is all digits. - Build head/base URLs on the PR's host rather than github.com. - Read the PR fields unit-separated: tab is IFS whitespace, so an empty field shifted every later one. Die clearly when the head repo is gone. - --no-push only writes a local ref, so it no longer asks to force-push. - The PR picker shows owner:branch; $default is now $base_branch, since in PR mode it is the base branch, not a default branch. --- scripts/sign-branch.sh | 74 +++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/scripts/sign-branch.sh b/scripts/sign-branch.sh index 8a83261..01a9459 100755 --- a/scripts/sign-branch.sh +++ b/scripts/sign-branch.sh @@ -4,6 +4,7 @@ # scripts/sign-branch.sh [] [--yes] [--no-push] # scripts/sign-branch.sh [--yes] [--no-push] # scripts/sign-branch.sh --pr [--yes] [--no-push] +# scripts/sign-branch.sh --branch ... (a branch whose name is all digits) # # With no branch, the ten most recently pushed branches on origin are listed and one is # picked with the arrow keys (or j/k, a digit, Enter; q to quit). With --pr and no @@ -51,10 +52,11 @@ self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") die() { echo "sign-branch: $*" >&2; exit 1; } usage() { awk 'NR > 1 && !/^#/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "$self"; } -target=; pr_mode=0; assume_yes=0; push=1 +target=; pr_mode=0; branch_mode=0; assume_yes=0; push=1 while [ $# -gt 0 ]; do case "$1" in --pr) pr_mode=1; shift ;; + --branch) branch_mode=1; shift ;; --yes|-y) assume_yes=1; shift ;; --no-push) push=0; shift ;; -h|--help) usage; exit 0 ;; @@ -63,8 +65,10 @@ while [ $# -gt 0 ]; do target=$1; shift ;; esac done +[ "$pr_mode" = 0 ] || [ "$branch_mode" = 0 ] || die "--pr and --branch are exclusive" # A number, #number or pull request URL names a PR; anything else is a branch on origin. -case "$target" in +# --branch forces the latter, for a branch whose name is all digits. +[ "$branch_mode" = 1 ] || case "$target" in https://*/pull/[0-9]*) pr_mode=1 ;; '' | '#' | *[!0-9#]* | ?*'#'*) ;; *) pr_mode=1; target=${target#\#} ;; @@ -121,12 +125,13 @@ if [ "$pr_mode" = 1 ]; then [ -t 0 ] && [ -t 1 ] || die "no pull request given, and no terminal to pick one from" echo "fetching open pull requests…" nums=(); labels=() - while IFS=$'\t' read -r num author name title; do + while IFS=$'\t' read -r num author head title; do nums+=("$num") - labels+=("$(printf '#%-6s %-16.16s %-30.30s %.40s' "$num" "$author" "$name" "$title")") + labels+=("$(printf '#%-6s %-16.16s %-40.40s %.40s' "$num" "$author" "$head" "$title")") done < <(gh pr list --state open --limit "$recent" --search 'sort:updated-desc' \ - --json number,author,headRefName,title \ - --jq '.[] | [.number, .author.login, .headRefName, .title] | @tsv') + --json number,author,headRepositoryOwner,headRefName,title \ + --jq '.[] | [.number, .author.login, + .headRepositoryOwner.login + ":" + .headRefName, .title] | @tsv') [ "${#nums[@]}" -gt 0 ] || die "no open pull requests" echo echo "which pull request to sign? (${#nums[@]} most recently updated)" @@ -135,32 +140,46 @@ if [ "$pr_mode" = 1 ]; then echo fi - IFS=$'\t' read -r branch default head_repo base_repo state cross can_modify < <( + # Unit-separated, not tab-separated: tab is IFS whitespace, so an empty field would + # collapse into its neighbour and shift every later one. + IFS=$'\x1f' read -r branch base_branch head_repo host base_repo state cross can_modify < <( gh pr view "$target" \ --json headRefName,baseRefName,headRepositoryOwner,headRepository,url,state,isCrossRepository,maintainerCanModify \ - --jq '[.headRefName, .baseRefName, .headRepositoryOwner.login + "/" + .headRepository.name, - (.url | capture("github.com/(?[^/]+/[^/]+)/pull").r), - .state, .isCrossRepository, .maintainerCanModify] | @tsv' + --jq '(.url | capture("^https?://(?[^/]+)/(?[^/]+/[^/]+)/pull/")) as $u + | [.headRefName, .baseRefName, + (if .headRepository then .headRepositoryOwner.login + "/" + .headRepository.name + else "" end), + $u.h, $u.r, .state, .isCrossRepository, .maintainerCanModify] + | map(tostring) | join("\u001f")' ) || die "cannot read pull request $target" [ "$state" = OPEN ] || die "pull request $target is $state" + [ -n "$head_repo" ] || die "pull request $target's head repository no longer exists" [ "$cross" != true ] || [ "$can_modify" = true ] \ || die "pull request $target is from a fork that does not allow maintainer edits" - [ "$cross" = true ] || [ "$branch" != "$default" ] \ - || die "$branch IS the pull request's base branch — refusing to rewrite it" - # Head and base by URL rather than a remote, since a fork's head usually has none; in - # origin's protocol, so whatever credentials origin uses apply. + # Head and base by URL rather than a remote, since a fork's head usually has none; on the + # PR's host, in origin's protocol, so whatever credentials origin uses apply. case "$(git remote get-url origin 2>/dev/null || true)" in - https://*) repo_url() { echo "https://github.com/$1.git"; } ;; - *) repo_url() { echo "git@github.com:$1.git"; } ;; + https://*) repo_url() { echo "https://$host/$1.git"; } ;; + *) repo_url() { echo "git@$host:$1.git"; } ;; esac src=$(repo_url "$head_repo"); base_src=$(repo_url "$base_repo"); where=$head_repo + + # A PR from a branch of the base repo itself may have that repo's default branch as its + # head (main into a release branch, say); that is shared history, never ours to rewrite. + # A fork's default branch is the contributor's own, so it is fair game. + if [ "$cross" != true ]; then + head_default=$(git ls-remote --symref "$src" HEAD | sed -n 's|^ref: refs/heads/||p' | awk '{print $1}') + [ -n "$head_default" ] || die "cannot determine $head_repo's default branch" + [ "$branch" != "$head_default" ] \ + || die "$branch IS $head_repo's default branch — refusing to rewrite it" + fi else src=origin; base_src=origin; where=origin # Ask the remote what its default branch is, rather than reading a local ref: this # works in a bare or fresh clone that has no refs/remotes/* at all, and cannot go stale. - default=$(git ls-remote --symref origin HEAD | sed -n 's|^ref: refs/heads/||p' | awk '{print $1}') - [ -n "$default" ] || die "cannot determine origin's default branch" + base_branch=$(git ls-remote --symref origin HEAD | sed -n 's|^ref: refs/heads/||p' | awk '{print $1}') + [ -n "$base_branch" ] || die "cannot determine origin's default branch" branch=$target # No branch named: offer the most recently pushed branches on origin. Fetched into @@ -172,14 +191,14 @@ else || die "cannot fetch branches from origin" names=(); labels=() while IFS=$'\t' read -r name date author subject; do - case "$name" in "$default" | HEAD) continue ;; esac + case "$name" in "$base_branch" | HEAD) continue ;; esac names+=("$name") labels+=("$(printf '%-36.36s %-14.14s %-16.16s %.40s' "$name" "$date" "$author" "$subject")") [ "${#names[@]}" -lt "$recent" ] || break done < <(git for-each-ref --sort=-committerdate \ --format='%(refname:lstrip=3)%09%(committerdate:relative)%09%(authorname)%09%(contents:subject)' \ refs/remotes/origin/) - [ "${#names[@]}" -gt 0 ] || die "origin has no branches other than $default" + [ "${#names[@]}" -gt 0 ] || die "origin has no branches other than $base_branch" echo echo "which branch to sign? (${#names[@]} most recently pushed; tip author shown)" menu "${labels[@]}" @@ -187,22 +206,22 @@ else echo fi - [ "$branch" != "$default" ] || die "$branch IS origin's default branch — refusing to rewrite it" + [ "$branch" != "$base_branch" ] || die "$branch IS origin's default branch — refusing to rewrite it" fi echo "fetching $where…" # FETCH_HEAD, not origin/: a bare clone has no remote-tracking refs, and it is # precisely the SHA just fetched — which is what the push lease below must pin. Fetch the # default branch first, since each fetch overwrites FETCH_HEAD. -git fetch --quiet "$base_src" "$default" || die "cannot fetch $default" -default_sha=$(git rev-parse FETCH_HEAD^{commit}) +git fetch --quiet "$base_src" "$base_branch" || die "cannot fetch $base_branch" +base_sha=$(git rev-parse FETCH_HEAD^{commit}) git fetch --quiet "$src" "$branch" || die "no branch '$branch' on $where" head_sha=$(git rev-parse FETCH_HEAD^{commit}) # The fork point, so only what this branch added is ever in scope. -base=$(git merge-base "$default_sha" "$head_sha") || die "$branch shares no history with $default" +base=$(git merge-base "$base_sha" "$head_sha") || die "$branch shares no history with $base_branch" [ "$(git rev-list --count "$base..$head_sha")" -gt 0 ] \ - || die "$branch adds nothing on top of $default — nothing to do" + || die "$branch adds nothing on top of $base_branch — nothing to do" # The raw object is the signature check: `git log --format=%G?` only reports a *verified* # signature, which needs gpg.ssh.allowedSignersFile set, and its absence would otherwise @@ -222,7 +241,7 @@ done < <(git rev-list --reverse "$base..$head_sha") upstream=$(git rev-parse "$first_bad^") echo -echo "branch: $where:$branch forked from: $default author: $me_name <$me_email>" +echo "branch: $where:$branch forked from: $base_branch author: $me_name <$me_email>" if [ "$(git rev-list --count "$base..$upstream")" -gt 0 ]; then echo "keeping untouched:" git log --reverse --format=' %h %an %s' "$base..$upstream" @@ -231,7 +250,8 @@ echo "rebuilding:" git log --reverse --format=' %h %an %s' "$upstream..$head_sha" echo -if [ "$assume_yes" != 1 ]; then +# --no-push only writes a local ref, so there is nothing to confirm. +if [ "$assume_yes" != 1 ] && [ "$push" = 1 ]; then printf 'rebuild and force-push to %s:%s? [y/N] ' "$where" "$branch" read -r reply case "$reply" in y | Y | yes | YES) ;; *) die "aborted" ;; esac From ab8c271b6bfd8f63420c341ece3f3bd3ad302472 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Mon, 28 Sep 2026 17:48:05 +0200 Subject: [PATCH 4/4] fix(scripts): brace $where before the ellipsis in sign-branch.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under bash 3.2 in a non-UTF-8 locale the first byte of "…" was read as part of the variable name, so "fetching $where…" died with an unbound variable. --- scripts/sign-branch.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sign-branch.sh b/scripts/sign-branch.sh index 01a9459..7a9276c 100755 --- a/scripts/sign-branch.sh +++ b/scripts/sign-branch.sh @@ -209,7 +209,7 @@ else [ "$branch" != "$base_branch" ] || die "$branch IS origin's default branch — refusing to rewrite it" fi -echo "fetching $where…" +echo "fetching ${where}…" # FETCH_HEAD, not origin/: a bare clone has no remote-tracking refs, and it is # precisely the SHA just fetched — which is what the push lease below must pin. Fetch the # default branch first, since each fetch overwrites FETCH_HEAD.