/ gitlog-to-changelog
gitlog-to-changelog
  1  #!/bin/sh
  2  #! -*-perl-*-
  3  
  4  # Convert git log output to ChangeLog format.
  5  
  6  # Copyright (C) 2008-2022 Free Software Foundation, Inc.
  7  # Copyright (C) 2022 Akib Azmain Turja.
  8  #
  9  # This program is free software: you can redistribute it and/or modify
 10  # it under the terms of the GNU General Public License as published by
 11  # the Free Software Foundation, either version 3 of the License, or
 12  # (at your option) any later version.
 13  #
 14  # This program is distributed in the hope that it will be useful,
 15  # but WITHOUT ANY WARRANTY; without even the implied warranty of
 16  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 17  # GNU General Public License for more details.
 18  #
 19  # You should have received a copy of the GNU General Public License
 20  # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 21  #
 22  # Written by Jim Meyering
 23  # '--ignore-commits' implemented by Akib Azmain Turja.
 24  
 25  # This is a prologue that allows to run a perl script as an executable
 26  # on systems that are compliant to a POSIX version before POSIX:2017.
 27  # On such systems, the usual invocation of an executable through execlp()
 28  # or execvp() fails with ENOEXEC if it is a script that does not start
 29  # with a #! line.  The script interpreter mentioned in the #! line has
 30  # to be /bin/sh, because on GuixSD systems that is the only program that
 31  # has a fixed file name.  The second line is essential for perl and is
 32  # also useful for editing this file in Emacs.  The next two lines below
 33  # are valid code in both sh and perl.  When executed by sh, they re-execute
 34  # the script through the perl program found in $PATH.  The '-x' option
 35  # is essential as well; without it, perl would re-execute the script
 36  # through /bin/sh.  When executed by perl, the next two lines are a no-op.
 37  eval 'exec perl -wSx "$0" "$@"'
 38       if 0;
 39  
 40  my $VERSION = '2022-11-29 10:18'; # UTC
 41  # The definition above must lie within the first 8 lines in order
 42  # for the Emacs time-stamp write hook (at end) to update it.
 43  # If you change this file with Emacs, please let the write hook
 44  # do its job.  Otherwise, update this string manually.
 45  
 46  use strict;
 47  use warnings;
 48  use Getopt::Long;
 49  use POSIX qw(strftime);
 50  
 51  (my $ME = $0) =~ s|.*/||;
 52  
 53  # use File::Coda; # https://meyering.net/code/Coda/
 54  END {
 55    defined fileno STDOUT or return;
 56    close STDOUT and return;
 57    warn "$ME: failed to close standard output: $!\n";
 58    $? ||= 1;
 59  }
 60  
 61  sub usage ($)
 62  {
 63    my ($exit_code) = @_;
 64    my $STREAM = ($exit_code == 0 ? *STDOUT : *STDERR);
 65    if ($exit_code != 0)
 66      {
 67        print $STREAM "Try '$ME --help' for more information.\n";
 68      }
 69    else
 70      {
 71        print $STREAM <<EOF;
 72  Usage: $ME [OPTIONS] [ARGS]
 73  
 74  Convert git log output to ChangeLog format.  If present, any ARGS
 75  are passed to "git log".  To avoid ARGS being parsed as options to
 76  $ME, they may be preceded by '--'.
 77  
 78  OPTIONS:
 79  
 80     --amend=FILE FILE maps from an SHA1 to perl code (i.e., s/old/new/) that
 81                    makes a change to SHA1's commit log text or metadata.
 82     --append-dot append a dot to the first line of each commit message if
 83                    there is no other punctuation or blank at the end.
 84     --no-cluster never cluster commit messages under the same date/author
 85                    header; the default is to cluster adjacent commit messages
 86                    if their headers are the same and neither commit message
 87                    contains multiple paragraphs.
 88     --srcdir=DIR the root of the source tree, from which the .git/
 89                    directory can be derived.
 90     --since=DATE convert only the logs since DATE;
 91                    the default is to convert all log entries.
 92     --until=DATE convert only the logs older than DATE.
 93     --ignore-commits=HASHES Comma-separated list of commits to ignore
 94     --ignore-matching=PAT ignore commit messages whose first lines match PAT.
 95     --ignore-line=PAT ignore lines of commit messages that match PAT.
 96     --format=FMT set format string for commit subject and body;
 97                    see 'man git-log' for the list of format metacharacters;
 98                    the default is '%s%n%b%n'
 99     --strip-tab  remove one additional leading TAB from commit message lines.
100     --strip-cherry-pick  remove data inserted by "git cherry-pick";
101                    this includes the "cherry picked from commit ..." line,
102                    and the possible final "Conflicts:" paragraph.
103     --help       display this help and exit
104     --version    output version information and exit
105  
106  EXAMPLE:
107  
108    $ME --since=2008-01-01 > ChangeLog
109    $ME -- -n 5 foo > last-5-commits-to-branch-foo
110  
111  SPECIAL SYNTAX:
112  
113  The following types of strings are interpreted specially when they appear
114  at the beginning of a log message line.  They are not copied to the output.
115  
116    Copyright-paperwork-exempt: Yes
117      Append the "(tiny change)" notation to the usual "date name email"
118      ChangeLog header to mark a change that does not require a copyright
119      assignment.
120    Co-authored-by: Joe User <user\@example.com>
121      List the specified name and email address on a second
122      ChangeLog header, denoting a co-author.
123    Signed-off-by: Joe User <user\@example.com>
124      These lines are simply elided.
125  
126  In a FILE specified via --amend, comment lines (starting with "#") are ignored.
127  FILE must consist of <SHA,CODE+> pairs where SHA is a 40-byte SHA1 (alone on
128  a line) referring to a commit in the current project, and CODE refers to one
129  or more consecutive lines of Perl code.  Pairs must be separated by one or
130  more blank line.
131  
132  Here is sample input for use with --amend=FILE, from coreutils:
133  
134  3a169f4c5d9159283548178668d2fae6fced3030
135  # fix typo in title:
136  s/all tile types/all file types/
137  
138  1379ed974f1fa39b12e2ffab18b3f7a607082202
139  # Due to a bug in vc-dwim, I mis-attributed a patch by Paul to myself.
140  # Change the author to be Paul.  Note the escaped "@":
141  s,Jim .*>,Paul Eggert <eggert\\\@cs.ucla.edu>,
142  
143  EOF
144      }
145    exit $exit_code;
146  }
147  
148  # If the string $S is a well-behaved file name, simply return it.
149  # If it contains white space, quotes, etc., quote it, and return the new string.
150  sub shell_quote($)
151  {
152    my ($s) = @_;
153    if ($s =~ m![^\w+/.,-]!)
154      {
155        # Convert each single quote to '\''
156        $s =~ s/\'/\'\\\'\'/g;
157        # Then single quote the string.
158        $s = "'$s'";
159      }
160    return $s;
161  }
162  
163  sub quoted_cmd(@)
164  {
165    return join (' ', map {shell_quote $_} @_);
166  }
167  
168  # Parse file F.
169  # Comment lines (starting with "#") are ignored.
170  # F must consist of <SHA,CODE+> pairs where SHA is a 40-byte SHA1
171  # (alone on a line) referring to a commit in the current project, and
172  # CODE refers to one or more consecutive lines of Perl code.
173  # Pairs must be separated by one or more blank line.
174  sub parse_amend_file($)
175  {
176    my ($f) = @_;
177  
178    open F, '<', $f
179      or die "$ME: $f: failed to open for reading: $!\n";
180  
181    my $fail;
182    my $h = {};
183    my $in_code = 0;
184    my $sha;
185    while (defined (my $line = <F>))
186      {
187        $line =~ /^\#/
188          and next;
189        chomp $line;
190        $line eq ''
191          and $in_code = 0, next;
192  
193        if (!$in_code)
194          {
195            $line =~ /^([[:xdigit:]]{40})$/
196              or (warn "$ME: $f:$.: invalid line; expected an SHA1\n"),
197                $fail = 1, next;
198            $sha = lc $1;
199            $in_code = 1;
200            exists $h->{$sha}
201              and (warn "$ME: $f:$.: duplicate SHA1\n"),
202                $fail = 1, next;
203          }
204        else
205          {
206            $h->{$sha} ||= '';
207            $h->{$sha} .= "$line\n";
208          }
209      }
210    close F;
211  
212    $fail
213      and exit 1;
214  
215    return $h;
216  }
217  
218  # git_dir_option $SRCDIR
219  #
220  # From $SRCDIR, the --git-dir option to pass to git (none if $SRCDIR
221  # is undef).  Return as a list (0 or 1 element).
222  sub git_dir_option($)
223  {
224    my ($srcdir) = @_;
225    my @res = ();
226    if (defined $srcdir)
227      {
228        my $qdir = shell_quote $srcdir;
229        my $cmd = "cd $qdir && git rev-parse --show-toplevel";
230        my $qcmd = shell_quote $cmd;
231        my $git_dir = qx($cmd);
232        defined $git_dir
233          or die "$ME: cannot run $qcmd: $!\n";
234        $? == 0
235          or die "$ME: $qcmd had unexpected exit code or signal ($?)\n";
236        chomp $git_dir;
237        push @res, "--git-dir=$git_dir/.git";
238      }
239    @res;
240  }
241  
242  {
243    my $since_date;
244    my $until_date;
245    my $format_string = '%s%n%b%n';
246    my $amend_file;
247    my $append_dot = 0;
248    my $cluster = 1;
249    my $ignore_commits = '';
250    my $ignore_matching;
251    my $ignore_line;
252    my $strip_tab = 0;
253    my $strip_cherry_pick = 0;
254    my $srcdir;
255    GetOptions
256      (
257       help => sub { usage 0 },
258       version => sub { print "$ME version $VERSION\n"; exit },
259       'since=s' => \$since_date,
260       'until=s' => \$until_date,
261       'format=s' => \$format_string,
262       'amend=s' => \$amend_file,
263       'append-dot' => \$append_dot,
264       'cluster!' => \$cluster,
265       'ignore-commits=s' => \$ignore_commits,
266       'ignore-matching=s' => \$ignore_matching,
267       'ignore-line=s' => \$ignore_line,
268       'strip-tab' => \$strip_tab,
269       'strip-cherry-pick' => \$strip_cherry_pick,
270       'srcdir=s' => \$srcdir,
271      ) or usage 1;
272  
273    defined $since_date
274      and unshift @ARGV, "--since=$since_date";
275    defined $until_date
276      and unshift @ARGV, "--until=$until_date";
277  
278    # This is a hash that maps an SHA1 to perl code (i.e., s/old/new/)
279    # that makes a correction in the log or attribution of that commit.
280    my $amend_code = defined $amend_file ? parse_amend_file $amend_file : {};
281  
282    my @cmd = ('git',
283               git_dir_option $srcdir,
284               qw(log --log-size),
285               '--pretty=format:%H:%ct  %an  <%ae>%n%n'.$format_string, @ARGV);
286    open PIPE, '-|', @cmd
287      or die ("$ME: failed to run '". quoted_cmd (@cmd) ."': $!\n"
288              . "(Is your Git too old?  Version 1.5.1 or later is required.)\n");
289  
290    my $prev_multi_paragraph;
291    my $prev_date_line = '';
292    my @prev_coauthors = ();
293    my @skipshas = ();
294  
295    while (1)
296      {
297        defined (my $in = <PIPE>)
298          or last;
299        $in =~ /^log size (\d+)$/
300          or die "$ME:$.: Invalid line (expected log size):\n$in";
301        my $log_nbytes = $1;
302  
303        my $log;
304        my $n_read = read PIPE, $log, $log_nbytes;
305        $n_read == $log_nbytes
306          or die "$ME:$.: unexpected EOF\n";
307  
308        # Extract leading hash.
309        my ($sha, $rest) = split ':', $log, 2;
310        defined $sha
311          or die "$ME:$.: malformed log entry\n";
312        $sha =~ /^[[:xdigit:]]{40}$/
313          or die "$ME:$.: invalid SHA1: $sha\n";
314  
315        my $skipflag = 0;
316  
317        if (@skipshas)
318          {
319            foreach(@skipshas)
320              {
321                if ($sha =~ /^$_/)
322                  {
323                    $skipflag = $_;
324                    last;
325                  }
326              }
327          }
328  
329        # If this commit's log requires any transformation, do it now.
330        my $code = $amend_code->{$sha};
331        if (defined $code)
332          {
333            eval 'use Safe';
334            my $s = new Safe;
335            # Put the unpreprocessed entry into "$_".
336            $_ = $rest;
337  
338            # Let $code operate on it, safely.
339            my $r = $s->reval("$code")
340              or die "$ME:$.:$sha: failed to eval \"$code\":\n$@\n";
341  
342            # Note that we've used this entry.
343            delete $amend_code->{$sha};
344  
345            # Update $rest upon success.
346            $rest = $_;
347          }
348  
349        # Remove lines inserted by "git cherry-pick".
350        if ($strip_cherry_pick)
351          {
352            $rest =~ s/^\s*Conflicts:\n.*//sm;
353            $rest =~ s/^\s*\(cherry picked from commit [\da-f]+\)\n//m;
354          }
355  
356        my @line = split /[ \t]*\n/, $rest;
357        my $author_line = shift @line;
358        defined $author_line
359          or die "$ME:$.: unexpected EOF\n";
360        $author_line =~ /^(\d+)  (.*>)$/
361          or die "$ME:$.: Invalid line "
362            . "(expected date/author/email):\n$author_line\n";
363  
364        # Format 'Copyright-paperwork-exempt: Yes' as a standard ChangeLog
365        # `(tiny change)' annotation.
366        my $tiny = (grep (/^(?:Copyright-paperwork-exempt|Tiny-change):\s+[Yy]es$/, @line)
367                    ? '  (tiny change)' : '');
368  
369        my $date_line = sprintf "%s  %s$tiny\n",
370          strftime ("%Y-%m-%d", localtime ($1)), $2;
371  
372        my @coauthors = grep /^Co-authored-by:.*$/, @line;
373        # Omit meta-data lines we've already interpreted.
374        @line = grep !/^(?:Signed-off-by:[ ].*>$
375                         |Co-authored-by:[ ]
376                         |Copyright-paperwork-exempt:[ ]
377                         |Tiny-change:[ ]
378                         )/x, @line;
379  
380        # Remove leading and trailing blank lines.
381        if (@line)
382          {
383            while ($line[0] =~ /^\s*$/) { shift @line; }
384            while ($line[$#line] =~ /^\s*$/) { pop @line; }
385          }
386  
387        # Handle Emacs gitmerge.el "skipped" commits.
388        # Yes, this should be controlled by an option.  So sue me.
389        if ( grep /^(; )?Merge from /, @line )
390        {
391            my $found = 0;
392            foreach (@line)
393            {
394                if (grep /^The following commit.*skipped:$/, $_)
395                {
396                    $found = 1;
397                    ## Reset at each merge to reduce chance of false matches.
398                    @skipshas = ();
399                    next;
400                }
401                if ($found && $_ =~ /^([[:xdigit:]]{7,}) [^ ]/)
402                {
403                    push ( @skipshas, $1 );
404                }
405            }
406        }
407  
408        # Ignore commits that's in --ignore-commits, if specified.
409        my $ignored = 0;
410        foreach(split ',', $ignore_commits)
411        {
412            if ($sha =~ /^$_/)
413            {
414                $ignored = 1;
415                last;
416            }
417        }
418  
419        # Ignore commits that match the --ignore-matching pattern, if specified.
420        if ($ignored || (defined $ignore_matching && @line
421                         && $line[0] =~ /$ignore_matching/))
422          {
423            $skipflag = 1;
424          }
425        elsif ($skipflag)
426          {
427            ## Perhaps only warn if a pattern matches more than once?
428            warn "$ME: warning: skipping $sha due to $skipflag\n";
429          }
430  
431        if (! $skipflag)
432          {
433            if (defined $ignore_line && @line)
434              {
435                @line = grep ! /$ignore_line/, @line;
436                while ($line[$#line] =~ /^\s*$/) { pop @line; }
437              }
438  
439            # Record whether there are two or more paragraphs.
440            my $multi_paragraph = grep /^\s*$/, @line;
441  
442            # Format 'Co-authored-by: A U Thor <email@example.com>' lines in
443            # standard multi-author ChangeLog format.
444            for (@coauthors)
445              {
446                s/^Co-authored-by:\s*/\t    /;
447                s/\s*</  </;
448  
449                /<.*?@.*\..*>/
450                  or warn "$ME: warning: missing email address for "
451                    . substr ($_, 5) . "\n";
452              }
453  
454            # If clustering of commit messages has been disabled, if this header
455            # would be different from the previous date/name/etc. header,
456            # or if this or the previous entry consists of two or more paragraphs,
457            # then print the header.
458            if ( ! $cluster
459                || $date_line ne $prev_date_line
460                || "@coauthors" ne "@prev_coauthors"
461                || $multi_paragraph
462                || $prev_multi_paragraph)
463              {
464                $prev_date_line eq ''
465                  or print "\n";
466                print $date_line;
467                @coauthors
468                  and print join ("\n", @coauthors), "\n";
469              }
470            $prev_date_line = $date_line;
471            @prev_coauthors = @coauthors;
472            $prev_multi_paragraph = $multi_paragraph;
473  
474            # If there were any lines
475            if (@line == 0)
476              {
477                warn "$ME: warning: empty commit message:\n"
478                     . "  commit $sha\n  $date_line\n";
479              }
480            else
481              {
482                if ($append_dot)
483                  {
484                    # If the first line of the message has enough room, then
485                    if (length $line[0] < 72)
486                      {
487                        # append a dot if there is no other punctuation or blank
488                        # at the end.
489                        $line[0] =~ /[[:punct:]\s]$/
490                          or $line[0] .= '.';
491                      }
492                  }
493  
494                # Remove one additional leading TAB from each line.
495                $strip_tab
496                  and map { s/^\t// } @line;
497  
498                # Prefix each non-empty line with a TAB.
499                @line = map { length $_ ? "\t$_" : '' } @line;
500  
501                print "\n", join ("\n", @line), "\n";
502              }
503          }
504  
505        defined ($in = <PIPE>)
506          or last;
507        $in ne "\n"
508          and die "$ME:$.: unexpected line:\n$in";
509      }
510  
511    close PIPE
512      or die "$ME: error closing pipe from " . quoted_cmd (@cmd) . "\n";
513    # FIXME-someday: include $PROCESS_STATUS in the diagnostic
514  
515    # Complain about any unused entry in the --amend=F specified file.
516    my $fail = 0;
517    foreach my $sha (keys %$amend_code)
518      {
519        warn "$ME:$amend_file: unused entry: $sha\n";
520        $fail = 1;
521      }
522  
523    exit $fail;
524  }
525  
526  # Local Variables:
527  # mode: perl
528  # indent-tabs-mode: nil
529  # eval: (add-hook 'before-save-hook 'time-stamp)
530  # time-stamp-line-limit: 50
531  # time-stamp-start: "my $VERSION = '"
532  # time-stamp-format: "%:y-%02m-%02d %02H:%02M"
533  # time-stamp-time-zone: "UTC0"
534  # time-stamp-end: "'; # UTC"
535  # End: