Mark hash_corrupted() as pg_attribute_noreturn.
[pgsql.git] / src / tools / git_changelog
blobc9469b8bad1d7e8e67cff6655974b64812e1db24
1 #!/usr/bin/perl
3 # Copyright (c) 2021-2024, PostgreSQL Global Development Group
6 # src/tools/git_changelog
8 # Display all commits on active branches, merging together commits from
9 # different branches that occur close together in time and with identical
10 # log messages.
12 # By default, commits are annotated with branch and release info thus:
13 # Branch: REL8_3_STABLE Release: REL8_3_2 [92c3a8004] 2008-03-29 00:15:37 +0000
14 # This shows that the commit on REL8_3_STABLE was released in 8.3.2.
15 # Commits on master will usually instead have notes like
16 # Branch: master Release: REL8_4_BR [6fc9d4272] 2008-03-29 00:15:28 +0000
17 # showing that this commit is ancestral to release branches 8.4 and later.
18 # If no Release: marker appears, the commit hasn't yet made it into any
19 # release.
21 # The --brief option shortens that to a format like:
22 # YYYY-MM-DD [hash] abbreviated commit subject line
23 # Since the branch isn't shown, this is mainly useful in conjunction
24 # with --master-only.
26 # Most of the time, matchable commits occur in the same order on all branches,
27 # and we print them out in that order. However, if commit A occurs before
28 # commit B on branch X and commit B occurs before commit A on branch Y, then
29 # there's no ordering which is consistent with both branches. In such cases
30 # we sort a merged commit according to its timestamp on the newest branch
31 # it appears in.
33 # The default output of this script is meant for generating minor release
34 # notes, where we need to know which branches a merged commit affects.
36 # To generate major release notes, use:
37 # git_changelog --master-only --brief --oldest-first --since='start-date'
38 # To find the appropriate start date, use:
39 # git show --summary $(git merge-base REL_12_STABLE master)
40 # where the branch to mention is the previously forked-off branch. This
41 # shows the last commit before that branch was made.
43 # Note that --master-only is an imperfect filter, since it will not detect
44 # cases where a master patch was back-patched awhile later or with a slightly
45 # different commit message. To find such cases, it's a good idea to look
46 # through the output of
47 # git_changelog --non-master-only --oldest-first --since='start-date'
48 # and then remove anything from the --master-only output that would be
49 # duplicative.
52 use strict;
53 use warnings FATAL => 'all';
54 require Time::Local;
55 require Getopt::Long;
56 require IPC::Open2;
58 # Adjust this list when the set of interesting branches changes.
59 # (We could get this from "git branches", but not worth the trouble.)
60 # NB: master must be first!
61 my @BRANCHES = qw(master
62 REL_16_STABLE REL_15_STABLE REL_14_STABLE REL_13_STABLE
63 REL_12_STABLE REL_11_STABLE REL_10_STABLE REL9_6_STABLE REL9_5_STABLE
64 REL9_4_STABLE REL9_3_STABLE REL9_2_STABLE REL9_1_STABLE REL9_0_STABLE
65 REL8_4_STABLE REL8_3_STABLE REL8_2_STABLE REL8_1_STABLE REL8_0_STABLE
66 REL7_4_STABLE REL7_3_STABLE REL7_2_STABLE REL7_1_STABLE REL7_0_PATCHES
67 REL6_5_PATCHES REL6_4);
69 # Might want to make this parameter user-settable.
70 my $timestamp_slop = 24 * 60 * 60;
72 my $brief = 0;
73 my $details_after = 0;
74 my $post_date = 0;
75 my $master_only = 0;
76 my $non_master_only = 0;
77 my $oldest_first = 0;
78 my $since;
79 my @output_buffer;
80 my $output_line = '';
82 Getopt::Long::GetOptions(
83 'brief' => \$brief,
84 'details-after' => \$details_after,
85 'master-only' => \$master_only,
86 'non-master-only' => \$non_master_only,
87 'post-date' => \$post_date,
88 'oldest-first' => \$oldest_first,
89 'since=s' => \$since) || usage();
90 usage() if @ARGV;
92 my @git = qw(git log --format=fuller --date=iso);
93 push @git, '--since=' . $since if defined $since;
95 # Collect the release tag data
96 my %rel_tags;
99 my $cmd = "git for-each-ref refs/tags";
100 my $pid = IPC::Open2::open2(my $git_out, my $git_in, $cmd)
101 || die "can't run $cmd: $!";
102 while (my $line = <$git_out>)
104 if ($line =~ m|^([a-f0-9]+)\s+commit\s+refs/tags/(\S+)|)
106 my $commit = $1;
107 my $tag = $2;
108 if ( $tag =~ /^REL_\d+_\d+$/
109 || $tag =~ /^REL\d+_\d+$/
110 || $tag =~ /^REL\d+_\d+_\d+$/)
112 $rel_tags{$commit} = $tag;
116 waitpid($pid, 0);
117 my $child_exit_status = $? >> 8;
118 die "$cmd failed" if $child_exit_status != 0;
121 # Collect the commit data
122 my %all_commits;
123 my %all_commits_by_branch;
125 # This remembers where each branch sprouted from master. Note the values
126 # will be wrong if --since terminates the log listing before the branch
127 # sprouts; but in that case it doesn't matter since we also won't reach
128 # the part of master where it would matter.
129 my %sprout_tags;
131 for my $branch (@BRANCHES)
133 my @cmd = @git;
134 if ($branch eq "master")
136 push @cmd, "origin/$branch";
138 else
140 push @cmd, "--parents";
141 push @cmd, "master..origin/$branch";
143 my $pid = IPC::Open2::open2(my $git_out, my $git_in, @cmd)
144 || die "can't run @cmd: $!";
145 my $last_tag = undef;
146 my $last_parent;
147 my %commit;
148 while (my $line = <$git_out>)
150 if ($line =~ /^commit\s+(\S+)/)
152 push_commit(\%commit) if %commit;
153 $last_tag = $rel_tags{$1} if defined $rel_tags{$1};
154 %commit = (
155 'branch' => $branch,
156 'commit' => $1,
157 'last_tag' => $last_tag,
158 'message' => '',);
159 if ($line =~ /^commit\s+\S+\s+(\S+)/)
161 $last_parent = $1;
163 else
165 $last_parent = undef;
168 elsif ($line =~ /^Author:\s+(.*)/)
170 $commit{'author'} = $1;
172 elsif ($line =~ /^CommitDate:\s+(.*)/)
174 $commit{'date'} = $1;
176 elsif ($line =~ /^\s\s/)
178 $commit{'message'} .= $line;
181 push_commit(\%commit) if %commit;
182 $sprout_tags{$last_parent} = $branch if defined $last_parent;
183 waitpid($pid, 0);
184 my $child_exit_status = $? >> 8;
185 die "@cmd failed" if $child_exit_status != 0;
188 # Run through the master branch and apply tags. We already tagged the other
189 # branches, but master needs a separate pass after we've acquired the
190 # sprout_tags data. Also, in post-date mode we need to add phony entries
191 # for branches that sprouted after a particular master commit was made.
193 my $last_tag = undef;
194 my %sprouted_branches;
195 for my $cc (@{ $all_commits_by_branch{'master'} })
197 my $commit = $cc->{'commit'};
198 my $c = $cc->{'commits'}->[0];
199 $last_tag = $rel_tags{$commit} if defined $rel_tags{$commit};
200 if (defined $sprout_tags{$commit})
202 $last_tag = $sprout_tags{$commit};
204 # normalize branch names for making sprout tags
205 $last_tag =~ s/^(REL_\d+).*/$1_BR/;
206 $last_tag =~ s/^(REL\d+_\d+).*/$1_BR/;
208 $c->{'last_tag'} = $last_tag;
209 if ($post_date)
211 if (defined $sprout_tags{$commit})
213 $sprouted_branches{ $sprout_tags{$commit} } = 1;
216 # insert new commits between master and any other commits
217 my @new_commits = (shift @{ $cc->{'commits'} });
218 for my $branch (reverse sort keys %sprouted_branches)
220 my $ccopy = { %{$c} };
221 $ccopy->{'branch'} = $branch;
222 push @new_commits, $ccopy;
224 $cc->{'commits'} = [ @new_commits, @{ $cc->{'commits'} } ];
229 my %position;
230 for my $branch (@BRANCHES)
232 $position{$branch} = 0;
235 while (1)
237 my $best_branch;
238 my $best_timestamp;
239 for my $branch (@BRANCHES)
241 my $leader = $all_commits_by_branch{$branch}->[ $position{$branch} ];
242 next if !defined $leader;
243 if (!defined $best_branch
244 || $leader->{'timestamp'} > $best_timestamp)
246 $best_branch = $branch;
247 $best_timestamp = $leader->{'timestamp'};
250 last if !defined $best_branch;
251 my $winner =
252 $all_commits_by_branch{$best_branch}->[ $position{$best_branch} ];
254 my $print_it = 1;
255 if ($master_only)
257 $print_it = (@{ $winner->{'commits'} } == 1)
258 && ($winner->{'commits'}[0]->{'branch'} eq 'master');
260 elsif ($non_master_only)
262 foreach my $c (@{ $winner->{'commits'} })
264 $print_it = 0 if ($c->{'branch'} eq 'master');
268 if ($print_it)
270 output_details($winner) if (!$details_after);
271 output_str("%s", $winner->{'message'} . "\n");
272 output_details($winner) if ($details_after);
273 unshift(@output_buffer, $output_line) if ($oldest_first);
274 $output_line = '';
277 $winner->{'done'} = 1;
278 for my $branch (@BRANCHES)
280 my $leader = $all_commits_by_branch{$branch}->[ $position{$branch} ];
281 if (defined $leader && $leader->{'done'})
283 ++$position{$branch};
284 redo;
289 print @output_buffer if ($oldest_first);
291 sub push_commit
293 my ($c) = @_;
294 my $ht = hash_commit($c);
295 my $ts = parse_datetime($c->{'date'});
296 my $cc;
298 # Note that this code will never merge two commits on the same branch,
299 # even if they have the same hash (author/message) and nearby
300 # timestamps. This means that there could be multiple potential
301 # matches when we come to add a commit from another branch. Prefer
302 # the closest-in-time one.
303 for my $candidate (@{ $all_commits{$ht} })
305 my $diff = abs($ts - $candidate->{'timestamp'});
306 if ($diff < $timestamp_slop
307 && !exists $candidate->{'branch_position'}{ $c->{'branch'} })
309 if (!defined $cc
310 || $diff < abs($ts - $cc->{'timestamp'}))
312 $cc = $candidate;
316 if (!defined $cc)
318 $cc = {
319 'author' => $c->{'author'},
320 'message' => $c->{'message'},
321 'commit' => $c->{'commit'},
322 'commits' => [],
323 'timestamp' => $ts
325 push @{ $all_commits{$ht} }, $cc;
328 # stash only the fields we'll need later
329 my $smallc = {
330 'branch' => $c->{'branch'},
331 'commit' => $c->{'commit'},
332 'date' => $c->{'date'},
333 'last_tag' => $c->{'last_tag'}
335 push @{ $cc->{'commits'} }, $smallc;
336 push @{ $all_commits_by_branch{ $c->{'branch'} } }, $cc;
337 $cc->{'branch_position'}{ $c->{'branch'} } =
338 -1 + @{ $all_commits_by_branch{ $c->{'branch'} } };
339 return;
342 sub hash_commit
344 my ($c) = @_;
345 return $c->{'author'} . "\0" . $c->{'message'};
348 sub parse_datetime
350 my ($dt) = @_;
351 $dt =~
352 /^(\d\d\d\d)-(\d\d)-(\d\d)\s+(\d\d):(\d\d):(\d\d)\s+([-+])(\d\d)(\d\d)$/;
353 my $gm = Time::Local::timegm($6, $5, $4, $3, $2 - 1, $1);
354 my $tzoffset = ($8 * 60 + $9) * 60;
355 $tzoffset = -$tzoffset if $7 eq '-';
356 return $gm - $tzoffset;
359 sub output_str
361 ($oldest_first) ? ($output_line .= sprintf(shift, @_)) : printf(@_);
362 return;
365 sub output_details
367 my $item = shift;
369 if ($details_after)
371 $item->{'author'} =~ m{^(.*?)\s*<[^>]*>$};
373 # output only author name, not email address
374 output_str("(%s)\n", $1);
376 else
378 output_str("Author: %s\n", $item->{'author'});
380 foreach my $c (@{ $item->{'commits'} })
382 if ($brief)
384 $item->{'message'} =~ m/^\s*(.*)/;
386 output_str(
387 "%s [%s] %s\n",
388 substr($c->{'date'}, 0, 10),
389 substr($c->{'commit'}, 0, 9),
390 substr($1, 0, 56));
392 else
394 output_str("Branch: %s ", $c->{'branch'})
395 if (!$master_only);
396 output_str("Release: %s ", $c->{'last_tag'})
397 if (defined $c->{'last_tag'});
398 output_str("[%s] %s\n", substr($c->{'commit'}, 0, 9),
399 $c->{'date'});
402 output_str("\n");
403 return;
406 sub usage
408 print STDERR <<EOM;
409 Usage: git_changelog [--brief/-b] [--details-after/-d] [--master-only/-m] [--non-master-only/-n] [--oldest-first/-o] [--post-date/-p] [--since=SINCE]
410 --brief Shorten commit descriptions, omitting branch identification
411 --details-after Show branch and author info after the commit description
412 --master-only Show only commits made just in the master branch
413 --non-master-only Show only commits made just in back branches
414 --oldest-first Show oldest commits first
415 --post-date Show branches made after a commit occurred
416 --since Show only commits dated since SINCE
418 exit 1;