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
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
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
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
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
53 use warnings FATAL
=> 'all';
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;
73 my $details_after = 0;
76 my $non_master_only = 0;
82 Getopt
::Long
::GetOptions
(
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
();
92 my @git = qw(git log --format=fuller --date=iso);
93 push @git, '--since=' . $since if defined $since;
95 # Collect the release tag data
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
+)|)
108 if ( $tag =~ /^REL_\d+_\d+$/
109 || $tag =~ /^REL\d+_\d+$/
110 || $tag =~ /^REL\d+_\d+_\d+$/)
112 $rel_tags{$commit} = $tag;
117 my $child_exit_status = $?
>> 8;
118 die "$cmd failed" if $child_exit_status != 0;
121 # Collect the commit data
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.
131 for my $branch (@BRANCHES)
134 if ($branch eq "master")
136 push @cmd, "origin/$branch";
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;
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};
157 'last_tag' => $last_tag,
159 if ($line =~ /^commit\s+\S+\s+(\S+)/)
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;
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;
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'} } ];
230 for my $branch (@BRANCHES)
232 $position{$branch} = 0;
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;
252 $all_commits_by_branch{$best_branch}->[ $position{$best_branch} ];
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');
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);
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};
289 print @output_buffer if ($oldest_first);
294 my $ht = hash_commit
($c);
295 my $ts = parse_datetime
($c->{'date'});
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'} })
310 || $diff < abs($ts - $cc->{'timestamp'}))
319 'author' => $c->{'author'},
320 'message' => $c->{'message'},
321 'commit' => $c->{'commit'},
325 push @
{ $all_commits{$ht} }, $cc;
328 # stash only the fields we'll need later
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'} } };
345 return $c->{'author'} . "\0" . $c->{'message'};
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;
361 ($oldest_first) ?
($output_line .= sprintf(shift, @_)) : printf(@_);
371 $item->{'author'} =~ m{^(.*?)\s*<[^>]*>$};
373 # output only author name, not email address
374 output_str
("(%s)\n", $1);
378 output_str
("Author: %s\n", $item->{'author'});
380 foreach my $c (@
{ $item->{'commits'} })
384 $item->{'message'} =~ m/^\s*(.*)/;
388 substr($c->{'date'}, 0, 10),
389 substr($c->{'commit'}, 0, 9),
394 output_str
("Branch: %s ", $c->{'branch'})
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),
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