From 5cef4148a2c4391780fac8b92d9dc3fec065a88d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 12:40:16 +0000 Subject: [PATCH] Attribute a backported commit to the pull request it came from A backport is a cherry-pick, so the only pull request its commit is associated with is the one that carried the backport. That pull request says nothing about the change, and every commit it brought over resolves to it, so a release branch's changelog credits all of its entries to one number -- 4.0.3 lists three, all of them #3005. `git cherry-pick -x` records the commit a cherry-pick was made from, so follow it: look the recorded origin up instead of the commit, and the changelog names the pull request the change was written and reviewed in. A commit backported twice carries one line per hop, so the first one, where the change started, is the one used. An origin that leads nowhere -- a commit cherry-picked from a fork, or one that reached the default branch without a pull request -- falls back to the commit in this history, which is at least the backport that brought it here. Cherry-picks made without `-x` record nothing to follow and behave as before. The commit messages are read with `%B`, so the output is forced to UTF-8: unlike the SHAs read until now, a message can hold anything, and splitting one that is not ASCII fails under `LANG=C`, as in CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- Rakefile | 100 ++++++++++++++++++++++++++++++++++++++---------- docs/release.md | 15 ++++++++ 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/Rakefile b/Rakefile index c4c400552e..33cef063fa 100644 --- a/Rakefile +++ b/Rakefile @@ -594,20 +594,54 @@ def changelog_commits(from, paths: []) output.lines.map(&:chomp).reject(&:empty?) end -# Finds the pull requests the commits came from, keeping the order of `commits`. +# What `git cherry-pick -x` appends to the message of the commit it creates. +CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/ + +# Maps the commits that record where they were cherry-picked from to that commit. # -# Returns the pull requests for the changelog and the ones omitted by `skip_labels`. +# A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit +# itself, that leads to the pull request the change was written and reviewed in. Without this a +# backported change is attributed to the pull request that carried the backport, which says +# nothing about the change and is the same for every commit it brought over. # -def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) - pull_requests = {} - skipped = {} +# A commit backported twice -- the development line, then a release branch -- carries one line +# per hop, appended in order, so the first one is where the change started. +# +def changelog_origins(commits) + return {} if commits.empty? + + require "open3" - commits.each_slice(50) do |slice| - # Ask GitHub which pull requests the commits came from, so that any merge strategy -- merge - # commit, squash, or rebase -- is handled without parsing commit messages. - aliases = slice.map.with_index do |commit, index| + # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message -- + # which can contain anything, including what this format looks like -- from being read as the + # format itself. + output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true) + raise status.inspect unless status.success? + + # Commit messages are UTF-8, while the default external encoding follows the locale. Without + # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions. + output.force_encoding(Encoding::UTF_8) + + output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins| + commit = commit.to_s.strip + next if commit.empty? + + origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next + origins[commit] = origin + end +end + +# Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge +# commit, squash, or rebase -- is handled without parsing commit messages. +# +# Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no +# merged pull request for, including the ones it does not know at all. +# +def changelog_associated_pull_requests(oids) + oids.uniq.each_slice(50).each_with_object({}) do |slice, found| + aliases = slice.map.with_index do |oid, index| <<~GRAPHQL - c#{index}: object(oid: "#{commit}") { + c#{index}: object(oid: "#{oid}") { ... on Commit { associatedPullRequests(first: 10) { nodes { @@ -620,18 +654,44 @@ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) GRAPHQL end - changelog_graphql(aliases.join("\n")).each_value do |commit| - next unless commit + response = changelog_graphql(aliases.join("\n")) - commit.dig(:associatedPullRequests, :nodes).each do |pr| - next unless pr[:merged] + slice.each_with_index do |oid, index| + nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || [] - pr = { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } } - if (pr[:labels] & skip_labels).empty? - pull_requests[pr[:number]] ||= pr - else - skipped[pr[:number]] ||= pr - end + found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr| + { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } } + end + end + end +end + +# Finds the pull requests the commits came from, keeping the order of `commits`. +# +# Returns the pull requests for the changelog and the ones omitted by `skip_labels`. +# +def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) + origins = changelog_origins(commits) + found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit }) + + # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the + # default branch without a pull request -- falls back to the commit in this history, which is + # at least the backport that brought it here. + fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? } + found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty? + + pull_requests = {} + skipped = {} + + commits.each do |commit| + prs = found.fetch(origins[commit] || commit, []) + prs = found.fetch(commit, []) if prs.empty? + + prs.each do |pr| + if (pr[:labels] & skip_labels).empty? + pull_requests[pr[:number]] ||= pr + else + skipped[pr[:number]] ||= pr end end end diff --git a/docs/release.md b/docs/release.md index 027b306e61..ffdffb6512 100644 --- a/docs/release.md +++ b/docs/release.md @@ -142,6 +142,21 @@ Without it the version on `master` keeps claiming to be the released version for development period, and `rake gem:changelog` reads that version to decide where the next changelog starts. +## Backports + +A patch release is cut from a release branch (`aaa-X.Y.x`), and what it carries beyond the previous +release is cherry-picked from the development line. Cherry-pick with `-x`: + +```console +$ git cherry-pick -x +``` + +`-x` records the commit the change was copied from, and that recorded line is what `rake +gem:changelog` follows to reach the pull request the change was written and reviewed in. Without +it, the only pull request a backported commit is associated with is the one that carried the +backport, which says nothing about the change and is the same for every commit it brought over — +that is why the 4.0.3 changelog credits its three entries to the same pull request. + ## Notes - Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`;