#!/usr/bin/perl # Copyright (c) 2021, PostgreSQL Global Development Group # # src/tools/git_changelog # # Display all commits on active branches, merging together commits from # different branches that occur close together in time and with identical # log messages. # # By default, commits are annotated with branch and release info thus: # Branch: REL8_3_STABLE Release: REL8_3_2 [92c3a8004] 2008-03-29 00:15:37 +0000 # This shows that the commit on REL8_3_STABLE was released in 8.3.2. # Commits on master will usually instead have notes like # Branch: master Release: REL8_4_BR [6fc9d4272] 2008-03-29 00:15:28 +0000 # showing that this commit is ancestral to release branches 8.4 and later. # If no Release: marker appears, the commit hasn't yet made it into any # release. # # The --brief option shortens that to a format like: # YYYY-MM-DD [hash] abbreviated commit subject line # Since the branch isn't shown, this is mainly useful in conjunction # with --master-only. # # Most of the time, matchable commits occur in the same order on all branches, # and we print them out in that order. However, if commit A occurs before # commit B on branch X and commit B occurs before commit A on branch Y, then # there's no ordering which is consistent with both branches. In such cases # we sort a merged commit according to its timestamp on the newest branch # it appears in. # # The default output of this script is meant for generating minor release # notes, where we need to know which branches a merged commit affects. # # To generate major release notes, use: # git_changelog --master-only --brief --oldest-first --since='start-date' # To find the appropriate start date, use: # git show --summary $(git merge-base REL_12_STABLE master) # where the branch to mention is the previously forked-off branch. This # shows the last commit before that branch was made. # # Note that --master-only is an imperfect filter, since it will not detect # cases where a master patch was back-patched awhile later or with a slightly # different commit message. To find such cases, it's a good idea to look # through the output of # git_changelog --non-master-only --oldest-first --since='start-date' # and then remove anything from the --master-only output that would be # duplicative. use strict; use warnings; require Time::Local; require Getopt::Long; require IPC::Open2; # Adjust this list when the set of interesting branches changes. # (We could get this from "git branches", but not worth the trouble.) # NB: master must be first! my @BRANCHES = qw(master REL_13_STABLE REL_12_STABLE REL_11_STABLE REL_10_STABLE REL9_6_STABLE REL9_5_STABLE REL9_4_STABLE REL9_3_STABLE REL9_2_STABLE REL9_1_STABLE REL9_0_STABLE REL8_4_STABLE REL8_3_STABLE REL8_2_STABLE REL8_1_STABLE REL8_0_STABLE REL7_4_STABLE REL7_3_STABLE REL7_2_STABLE REL7_1_STABLE REL7_0_PATCHES REL6_5_PATCHES REL6_4); # Might want to make this parameter user-settable. my $timestamp_slop = 24 * 60 * 60; my $brief = 0; my $details_after = 0; my $post_date = 0; my $master_only = 0; my $non_master_only = 0; my $oldest_first = 0; my $since; my @output_buffer; my $output_line = ''; Getopt::Long::GetOptions( 'brief' => \$brief, 'details-after' => \$details_after, 'master-only' => \$master_only, 'non-master-only' => \$non_master_only, 'post-date' => \$post_date, 'oldest-first' => \$oldest_first, 'since=s' => \$since) || usage(); usage() if @ARGV; my @git = qw(git log --format=fuller --date=iso); push @git, '--since=' . $since if defined $since; # Collect the release tag data my %rel_tags; { my $cmd = "git for-each-ref refs/tags"; my $pid = IPC::Open2::open2(my $git_out, my $git_in, $cmd) || die "can't run $cmd: $!"; while (my $line = <$git_out>) { if ($line =~ m|^([a-f0-9]+)\s+commit\s+refs/tags/(\S+)|) { my $commit = $1; my $tag = $2; if ( $tag =~ /^REL_\d+_\d+$/ || $tag =~ /^REL\d+_\d+$/ || $tag =~ /^REL\d+_\d+_\d+$/) { $rel_tags{$commit} = $tag; } } } waitpid($pid, 0); my $child_exit_status = $? >> 8; die "$cmd failed" if $child_exit_status != 0; } # Collect the commit data my %all_commits; my %all_commits_by_branch; # This remembers where each branch sprouted from master. Note the values # will be wrong if --since terminates the log listing before the branch # sprouts; but in that case it doesn't matter since we also won't reach # the part of master where it would matter. my %sprout_tags; for my $branch (@BRANCHES) { my @cmd = @git; if ($branch eq "master") { push @cmd, "origin/$branch"; } else { push @cmd, "--parents"; push @cmd, "master..origin/$branch"; } my $pid = IPC::Open2::open2(my $git_out, my $git_in, @cmd) || die "can't run @cmd: $!"; my $last_tag = undef; my $last_parent; my %commit; while (my $line = <$git_out>) { if ($line =~ /^commit\s+(\S+)/) { push_commit(\%commit) if %commit; $last_tag = $rel_tags{$1} if defined $rel_tags{$1}; %commit = ( 'branch' => $branch, 'commit' => $1, 'last_tag' => $last_tag, 'message' => '',); if ($line =~ /^commit\s+\S+\s+(\S+)/) { $last_parent = $1; } else { $last_parent = undef; } } elsif ($line =~ /^Author:\s+(.*)/) { $commit{'author'} = $1; } elsif ($line =~ /^CommitDate:\s+(.*)/) { $commit{'date'} = $1; } elsif ($line =~ /^\s\s/) { $commit{'message'} .= $line; } } push_commit(\%commit) if %commit; $sprout_tags{$last_parent} = $branch if defined $last_parent; waitpid($pid, 0); my $child_exit_status = $? >> 8; die "@cmd failed" if $child_exit_status != 0; } # Run through the master branch and apply tags. We already tagged the other # branches, but master needs a separate pass after we've acquired the # sprout_tags data. Also, in post-date mode we need to add phony entries # for branches that sprouted after a particular master commit was made. { my $last_tag = undef; my %sprouted_branches; for my $cc (@{ $all_commits_by_branch{'master'} }) { my $commit = $cc->{'commit'}; my $c = $cc->{'commits'}->[0]; $last_tag = $rel_tags{$commit} if defined $rel_tags{$commit}; if (defined $sprout_tags{$commit}) { $last_tag = $sprout_tags{$commit}; # normalize branch names for making sprout tags $last_tag =~ s/^(REL_\d+).*/$1_BR/; $last_tag =~ s/^(REL\d+_\d+).*/$1_BR/; } $c->{'last_tag'} = $last_tag; if ($post_date) { if (defined $sprout_tags{$commit}) { $sprouted_branches{ $sprout_tags{$commit} } = 1; } # insert new commits between master and any other commits my @new_commits = (shift @{ $cc->{'commits'} }); for my $branch (reverse sort keys %sprouted_branches) { my $ccopy = { %{$c} }; $ccopy->{'branch'} = $branch; push @new_commits, $ccopy; } $cc->{'commits'} = [ @new_commits, @{ $cc->{'commits'} } ]; } } } my %position; for my $branch (@BRANCHES) { $position{$branch} = 0; } while (1) { my $best_branch; my $best_timestamp; for my $branch (@BRANCHES) { my $leader = $all_commits_by_branch{$branch}->[ $position{$branch} ]; next if !defined $leader; if (!defined $best_branch || $leader->{'timestamp'} > $best_timestamp) { $best_branch = $branch; $best_timestamp = $leader->{'timestamp'}; } } last if !defined $best_branch; my $winner = $all_commits_by_branch{$best_branch}->[ $position{$best_branch} ]; my $print_it = 1; if ($master_only) { $print_it = (@{ $winner->{'commits'} } == 1) && ($winner->{'commits'}[0]->{'branch'} eq 'master'); } elsif ($non_master_only) { foreach my $c (@{ $winner->{'commits'} }) { $print_it = 0 if ($c->{'branch'} eq 'master'); } } if ($print_it) { output_details($winner) if (!$details_after); output_str("%s", $winner->{'message'} . "\n"); output_details($winner) if ($details_after); unshift(@output_buffer, $output_line) if ($oldest_first); $output_line = ''; } $winner->{'done'} = 1; for my $branch (@BRANCHES) { my $leader = $all_commits_by_branch{$branch}->[ $position{$branch} ]; if (defined $leader && $leader->{'done'}) { ++$position{$branch}; redo; } } } print @output_buffer if ($oldest_first); sub push_commit { my ($c) = @_; my $ht = hash_commit($c); my $ts = parse_datetime($c->{'date'}); my $cc; # Note that this code will never merge two commits on the same branch, # even if they have the same hash (author/message) and nearby # timestamps. This means that there could be multiple potential # matches when we come to add a commit from another branch. Prefer # the closest-in-time one. for my $candidate (@{ $all_commits{$ht} }) { my $diff = abs($ts - $candidate->{'timestamp'}); if ($diff < $timestamp_slop && !exists $candidate->{'branch_position'}{ $c->{'branch'} }) { if (!defined $cc || $diff < abs($ts - $cc->{'timestamp'})) { $cc = $candidate; } } } if (!defined $cc) { $cc = { 'author' => $c->{'author'}, 'message' => $c->{'message'}, 'commit' => $c->{'commit'}, 'commits' => [], 'timestamp' => $ts }; push @{ $all_commits{$ht} }, $cc; } # stash only the fields we'll need later my $smallc = { 'branch' => $c->{'branch'}, 'commit' => $c->{'commit'}, 'date' => $c->{'date'}, 'last_tag' => $c->{'last_tag'} }; push @{ $cc->{'commits'} }, $smallc; push @{ $all_commits_by_branch{ $c->{'branch'} } }, $cc; $cc->{'branch_position'}{ $c->{'branch'} } = -1 + @{ $all_commits_by_branch{ $c->{'branch'} } }; return; } sub hash_commit { my ($c) = @_; return $c->{'author'} . "\0" . $c->{'message'}; } sub parse_datetime { my ($dt) = @_; $dt =~ /^(\d\d\d\d)-(\d\d)-(\d\d)\s+(\d\d):(\d\d):(\d\d)\s+([-+])(\d\d)(\d\d)$/; my $gm = Time::Local::timegm($6, $5, $4, $3, $2 - 1, $1); my $tzoffset = ($8 * 60 + $9) * 60; $tzoffset = -$tzoffset if $7 eq '-'; return $gm - $tzoffset; } sub output_str { ($oldest_first) ? ($output_line .= sprintf(shift, @_)) : printf(@_); return; } sub output_details { my $item = shift; if ($details_after) { $item->{'author'} =~ m{^(.*?)\s*<[^>]*>$}; # output only author name, not email address output_str("(%s)\n", $1); } else { output_str("Author: %s\n", $item->{'author'}); } foreach my $c (@{ $item->{'commits'} }) { if ($brief) { $item->{'message'} =~ m/^\s*(.*)/; output_str( "%s [%s] %s\n", substr($c->{'date'}, 0, 10), substr($c->{'commit'}, 0, 9), substr($1, 0, 56)); } else { output_str("Branch: %s ", $c->{'branch'}) if (!$master_only); output_str("Release: %s ", $c->{'last_tag'}) if (defined $c->{'last_tag'}); output_str("[%s] %s\n", substr($c->{'commit'}, 0, 9), $c->{'date'}); } } output_str("\n"); return; } sub usage { print STDERR <