This is a live mirror of the Perl 5 development currently hosted at https://github.com/perl/perl5
Correct POD formatting error
[perl5.git] / Porting / updateAUTHORS.pl
1 #!/usr/bin/env perl
2 package Porting::updateAUTHORS;
3 use strict;
4 use warnings;
5 use Getopt::Long qw(GetOptions);
6 use Pod::Usage qw(pod2usage);
7 use Data::Dumper;
8 use Encode qw(encode_utf8 decode_utf8 decode);
9
10 # The style of this file is determined by:
11 #
12 # perltidy -w -ple -bbb -bbc -bbs -nolq -l=80 -noll -nola -nwls='=' \
13 #   -isbc -nolc -otr -kis -ci=4 -se -sot -sct -nsbl -pt=2 -fs  \
14 #   -fsb='#start-no-tidy' -fse='#end-no-tidy'
15
16 # Info and config for passing to git log.
17 #   %an: author name
18 #   %aN: author name (respecting .mailmap, see git-shortlog(1) or git-blame(1))
19 #   %ae: author email
20 #   %aE: author email (respecting .mailmap, see git-shortlog(1) or git-blame(1))
21 #   %cn: committer name
22 #   %cN: committer name (respecting .mailmap, see git-shortlog(1) or git-blame(1))
23 #   %ce: committer email
24 #   %cE: committer email (respecting .mailmap, see git-shortlog(1) or git-blame(1))
25 #   %H: commit hash
26 #   %h: abbreviated commit hash
27 #   %s: subject
28 #   %x00: print a byte from a hex code
29
30 my %field_spec= (
31     "an" => "author_name",
32     "aN" => "author_name_mm",
33     "ae" => "author_email",
34     "aE" => "author_email_mm",
35     "cn" => "committer_name",
36     "cN" => "committer_name_mm",
37     "ce" => "committer_email",
38     "cE" => "committer_email_mm",
39     "H"  => "commit_hash",
40     "h"  => "abbrev_hash",
41     "s"  => "commit_subject",
42 );
43
44 my @field_codes= sort keys %field_spec;
45 my @field_names= map { $field_spec{$_} } @field_codes;
46 my $tformat= join "%x00", map { "%" . $_ } @field_codes;
47
48 sub _make_name_author_info {
49     my ($author_info, $commit_info, $name_key)= @_;
50     (my $email_key= $name_key) =~ s/name/email/;
51     my $email= $commit_info->{$email_key};
52     my $name= $commit_info->{$name_key};
53
54     my $line= $author_info->{"email2line"}{$email}
55         // $author_info->{"name2line"}{$name};
56
57     $line //= sprintf "%-31s<%s>",
58         $commit_info->{$name_key}, $commit_info->{$email_key};
59     return $line;
60 }
61
62 sub _make_name_simple {
63     my ($commit_info, $key)= @_;
64     my $name_key= $key . "_name";
65     my $email_key= $key . "_email";
66     return sprintf "%s <%s>", $commit_info->{$name_key},
67         lc($commit_info->{$email_key});
68 }
69
70 sub read_commit_log {
71     my ($author_info, $mailmap_info)= @_;
72     $author_info ||= {};
73     open my $fh, qq(git log --pretty='tformat:$tformat' |);
74
75     while (defined(my $line= <$fh>)) {
76         chomp $line;
77         $line= decode_utf8($line);
78         my $commit_info= {};
79         @{$commit_info}{@field_names}= split /\0/, $line, 0 + @field_names;
80
81         my $author_name_mm= _make_name_author_info($author_info, $commit_info,
82             "author_name_mm");
83
84         my $committer_name_mm=
85             _make_name_author_info($author_info, $commit_info,
86             "committer_name_mm");
87
88         my $author_name_real= _make_name_simple($commit_info, "author");
89
90         my $committer_name_real= _make_name_simple($commit_info, "committer");
91
92         _check_name_mailmap(
93             $mailmap_info, $author_name_mm, $author_name_real,
94             $commit_info,  "author name"
95         );
96         _check_name_mailmap($mailmap_info, $committer_name_mm,
97             $committer_name_real, $commit_info, "committer name");
98
99         $author_info->{"lines"}{$author_name_mm}++;
100         $author_info->{"lines"}{$committer_name_mm}++;
101     }
102     return $author_info;
103 }
104
105 sub read_authors {
106     my ($authors_file)= @_;
107     $authors_file ||= "AUTHORS";
108
109     my @authors_preamble;
110     open my $in_fh, "<", $authors_file
111         or die "Failed to open for read '$authors_file': $!";
112     while (defined(my $line= <$in_fh>)) {
113         chomp $line;
114         push @authors_preamble, $line;
115         if ($line =~ /^--/) {
116             last;
117         }
118     }
119     my %author_info;
120     while (defined(my $line= <$in_fh>)) {
121         chomp $line;
122         $line= decode_utf8($line);
123         my ($name, $email);
124         my $copy= $line;
125         $copy =~ s/\s+\z//;
126         if ($copy =~ s/<([^<>]*)>//) {
127             $email= $1;
128         }
129         elsif ($copy =~ s/\s+(\@\w+)\z//) {
130             $email= $1;
131         }
132         $copy =~ s/\s+\z//;
133         $name= $copy;
134         $email //= "unknown";
135         $email= lc($email);
136
137         $author_info{"lines"}{$line}++;
138         $author_info{"email2line"}{$email}= $line
139             if $email and $email ne "unknown";
140         $author_info{"name2line"}{$name}= $line
141             if $name and $name ne "unknown";
142         $author_info{"email2name"}{ lc($email) }= $name
143             if $email
144             and $name
145             and $email ne "unknown";
146         $author_info{"name2email"}{$name}= $email
147             if $name and $name ne "unknown";
148     }
149     close $in_fh
150         or die "Failed to close '$authors_file': $!";
151     return (\%author_info, \@authors_preamble);
152 }
153
154 sub update_authors {
155     my ($author_info, $authors_preamble, $authors_file)= @_;
156     $authors_file ||= "AUTHORS";
157     my $authors_file_new= $authors_file . ".new";
158     open my $out_fh, ">", $authors_file_new
159         or die "Failed to open for write '$authors_file_new': $!";
160     foreach my $line (@$authors_preamble) {
161         print $out_fh encode_utf8($line), "\n"
162             or die "Failed to print to '$authors_file_new': $!";
163     }
164     foreach my $author (_sorted_hash_keys($author_info->{"lines"})) {
165         next if $author =~ /^unknown/;
166         if ($author =~ s/\s*<unknown>\z//) {
167             next if $author =~ /^\w+$/;
168         }
169         print $out_fh encode_utf8($author), "\n"
170             or die "Failed to print to '$authors_file_new': $!";
171     }
172     close $out_fh
173         or die "Failed to close '$authors_file_new': $!";
174     rename $authors_file_new, $authors_file
175         or die "Failed to rename '$authors_file_new' to '$authors_file':$!";
176     return 1;    # ok
177 }
178
179 sub read_mailmap {
180     my ($mailmap_file)= @_;
181     $mailmap_file ||= ".mailmap";
182
183     open my $in, "<", $mailmap_file
184         or die "Failed to read '$mailmap_file': $!";
185     my %mailmap_hash;
186     my @mailmap_preamble;
187     my $line_num= 0;
188     while (defined(my $line= <$in>)) {
189         ++$line_num;
190         next unless $line =~ /\S/;
191         chomp($line);
192         $line= decode_utf8($line);
193         if ($line =~ /^#/) {
194             if (!keys %mailmap_hash) {
195                 push @mailmap_preamble, $line;
196             }
197             else {
198                 die encode_utf8 "Not expecting comments after header ",
199                     "finished at line $line_num!\nLine: $line\n";
200             }
201         }
202         else {
203             $mailmap_hash{$line}= $line_num;
204         }
205     }
206     close $in;
207     return \%mailmap_hash, \@mailmap_preamble;
208 }
209
210 # this can be used to extract data from the checkAUTHORS data
211 sub merge_mailmap_with_AUTHORS_and_checkAUTHORS_data {
212     my ($mailmap_hash, $author_info)= @_;
213     require 'Porting/checkAUTHORS.pl' or die "No authors?";
214     my ($map, $preferred_email_or_github)=
215         Porting::checkAUTHORS::generate_known_author_map();
216
217     foreach my $old (sort keys %$preferred_email_or_github) {
218         my $new= $preferred_email_or_github->{$old};
219         next if $old !~ /\@/ or $new !~ /\@/ or $new eq $old;
220         my $name= $author_info->{"email2name"}{$new};
221         if ($name) {
222             my $line= "$name <$new> <$old>";
223             $mailmap_hash->{$line}++;
224         }
225     }
226     return 1;    # ok
227 }
228
229 sub _sorted_hash_keys {
230     my ($hash)= @_;
231     my @sorted= sort { lc($a) cmp lc($b) || $a cmp $b } keys %$hash;
232     return @sorted;
233 }
234
235 sub update_mailmap {
236     my ($mailmap_hash, $mailmap_preamble, $mailmap_file)= @_;
237     $mailmap_file ||= ".mailmap";
238
239     my $mailmap_file_new= $mailmap_file . "_new";
240     open my $out, ">", $mailmap_file_new
241         or die "Failed to write '$mailmap_file_new':$!";
242     foreach my $line (@$mailmap_preamble, _sorted_hash_keys($mailmap_hash),) {
243         print $out encode_utf8($line), "\n"
244             or die "Failed to print to '$mailmap_file': $!";
245     }
246     close $out;
247     rename $mailmap_file_new, $mailmap_file
248         or die "Failed to rename '$mailmap_file_new' to '$mailmap_file':$!";
249     return 1;    # ok
250 }
251
252 sub parse_mailmap_hash {
253     my ($mailmap_hash)= @_;
254     my @recs;
255     foreach my $line (sort keys %$mailmap_hash) {
256         my $line_num= $mailmap_hash->{$line};
257         $line =~ /^ \s* (?: ( [^<>]*? ) \s+ )? <([^<>]*)>
258                 (?: \s+ (?: ( [^<>]*? ) \s+ )? <([^<>]*)> )? \s* \z /x
259             or die encode_utf8 "Failed to parse line num $line_num: '$line'";
260         if (!$1 or !$2) {
261             die encode_utf8 "Both preferred name and email are mandatory ",
262                 "in line num $line_num: '$line'";
263         }
264
265         # [ preferred_name, preferred_email, other_name, other_email ]
266         push @recs, [ $1, $2, $3, $4, $line_num ];
267     }
268     return \@recs;
269 }
270
271 sub _safe_set_key {
272     my ($hash, $root_key, $key, $val, $pretty_name)= @_;
273     $hash->{$root_key}{$key} //= $val;
274     my $prev= $hash->{$root_key}{$key};
275     if ($prev ne $val) {
276         die encode_utf8 "Collision on mapping $root_key: "
277             . " '$key' maps to '$prev' and '$val'\n";
278     }
279 }
280
281 my $O2P= "other2preferred";
282 my $O2PN= "other2preferred_name";
283 my $O2PE= "other2preferred_email";
284 my $P2O= "preferred2other";
285 my $N2P= "name2preferred";
286 my $E2P= "email2preferred";
287
288 my $blurb= "";    # FIXME - replace with a nice message
289
290 sub _check_name_mailmap {
291     my ($mailmap_info, $auth_name, $raw_name, $commit_info, $descr)= @_;
292     my $name= $auth_name;
293     $name =~ s/<([^<>]+)>/<\L$1\E>/
294         or $name =~ s/(\s)(\@\w+)\z/$1<\L$2\E>/
295         or $name .= " <unknown>";
296
297     $name =~ s/\s+/ /g;
298
299     if (!$mailmap_info->{$P2O}{$name}) {
300         warn encode_utf8 sprintf "Unknown %s '%s' in commit %s '%s'\n%s",
301             $descr,
302             $name, $commit_info->{"abbrev_hash"},
303             $commit_info->{"commit_subject"},
304             $blurb;
305         $mailmap_info->{add}{"$name $raw_name"}++;
306         return 0;
307     }
308     elsif (!$mailmap_info->{$P2O}{$name}{$raw_name}) {
309         $mailmap_info->{add}{"$name $raw_name"}++;
310     }
311     return 1;
312 }
313
314 sub check_fix_mailmap_hash {
315     my ($mailmap_hash, $authors_info)= @_;
316     my $parsed= parse_mailmap_hash($mailmap_hash);
317     my @fixed;
318     my %seen_map;
319     my %pref_groups;
320
321     # first pass through the data, do any conversions, eg, LC
322     # the email address, decode any MIME-Header style email addresses.
323     # We also correct any preferred name entries so they match what
324     # we already have in AUTHORS, and check that there aren't collisions
325     # or other issues in the data.
326     foreach my $rec (@$parsed) {
327         my ($pname, $pemail, $oname, $oemail, $line_num)= @$rec;
328         $pemail= lc($pemail);
329         $oemail= lc($oemail) if defined $oemail;
330         if ($pname =~ /=\?UTF-8\?/) {
331             $pname= decode("MIME-Header", $pname);
332         }
333         my $auth_email= $authors_info->{"name2email"}{$pname};
334         if ($auth_email) {
335             ## this name exists in authors, so use its email data for pemail
336             $pemail= $auth_email;
337         }
338         my $auth_name= $authors_info->{"email2name"}{$pemail};
339         if ($auth_name) {
340             ## this email exists in authors, so use its name data for pname
341             $pname= $auth_name;
342         }
343
344         # neither name nor email exist in authors.
345         if ($pname ne "unknown") {
346             if (my $email= $seen_map{"name"}{$pname}) {
347                 ## we have seen this pname before, check the pemail
348                 ## is consistent
349                 if ($email ne $pemail) {
350                     warn encode_utf8 "Inconsistent emails for name '$pname'"
351                         . " at line num $line_num: keeping '$email',"
352                         . " ignoring '$pemail'\n";
353                     $pemail= $email;
354                 }
355             }
356             else {
357                 $seen_map{"name"}{$pname}= $pemail;
358             }
359         }
360         if ($pemail ne "unknown") {
361             if (my $name= $seen_map{"email"}{$pemail}) {
362                 ## we have seen this preferred_email before, check the preferred_name
363                 ## is consistent
364                 if ($name ne $pname) {
365                     warn encode_utf8 "Inconsistent name for email '$pemail'"
366                         . " at line num $line_num: keeping '$name', ignoring"
367                         . " '$pname'\n";
368                     $pname= $name;
369                 }
370             }
371             else {
372                 $seen_map{"email"}{$pemail}= $pname;
373             }
374         }
375
376         # Build an index of "preferred name/email" to other-email, other name
377         # we use this later to remove redundant entries missing a name.
378         $pref_groups{"$pname $pemail"}{$oemail}{ $oname || "" }=
379             [ $pname, $pemail, $oname, $oemail, $line_num ];
380     }
381
382     # this removes entries like
383     # Joe <blogs> <whatever>
384     # where there is a corresponding
385     # Joe <blogs> Joe X <blogs>
386     foreach my $pref (_sorted_hash_keys(\%pref_groups)) {
387         my $entries= $pref_groups{$pref};
388         foreach my $email (_sorted_hash_keys($entries)) {
389             my @names= _sorted_hash_keys($entries->{$email});
390             if ($names[0] eq "" and @names > 1) {
391                 shift @names;
392             }
393             foreach my $name (@names) {
394                 push @fixed, $entries->{$email}{$name};
395             }
396         }
397     }
398
399     # final pass through the dataset, build up a database
400     # we will use later for checks and updates, and reconstruct
401     # the canonical entries.
402     my $new_mailmap_hash= {};
403     my $mailmap_info=     {};
404     foreach my $rec (@fixed) {
405         my ($pname, $pemail, $oname, $oemail, $line_num)= @$rec;
406         my $preferred= "$pname <$pemail>";
407         my $other;
408         if (defined $oemail) {
409             $other= $oname ? "$oname <$oemail>" : "<$oemail>";
410         }
411         if ($other and $other ne "<unknown>") {
412             _safe_set_key($mailmap_info, $O2P,  $other, $preferred);
413             _safe_set_key($mailmap_info, $O2PN, $other, $pname);
414             _safe_set_key($mailmap_info, $O2PE, $other, $pemail);
415         }
416         $mailmap_info->{$P2O}{$preferred}{$other}++;
417         if ($pname ne "unknown") {
418             _safe_set_key($mailmap_info, $N2P, $pname, $preferred);
419         }
420         if ($pemail ne "unknown") {
421             _safe_set_key($mailmap_info, $E2P, $pemail, $preferred);
422         }
423         my $line= $preferred;
424         $line .= " $other" if $other;
425         $new_mailmap_hash->{$line}= $line_num;
426     }
427     return ($new_mailmap_hash, $mailmap_info);
428 }
429
430 sub add_new_mailmap_entries {
431     my ($mailmap_hash, $mailmap_info, $mailmap_file)= @_;
432
433     my $mailmap_add= $mailmap_info->{add}
434         or return 0;
435
436     my $num= 0;
437     for my $new (sort keys %$mailmap_add) {
438         !$mailmap_hash->{$new}++ or next;
439         warn encode_utf8 "Updating '$mailmap_file' with: $new\n";
440         $num++;
441     }
442     return $num;
443 }
444
445 sub read_and_update {
446     my ($authors_file, $mailmap_file)= @_;
447
448     # read the authors file and extract the info it contains
449     my ($author_info, $authors_preamble)= read_authors($authors_file);
450
451     # read the mailmap file.
452     my ($orig_mailmap_hash, $mailmap_preamble)= read_mailmap($mailmap_file);
453
454     # check and possibly fix the mailmap data, and build a set of precomputed
455     # datasets to work with it.
456     my ($mailmap_hash, $mailmap_info)=
457         check_fix_mailmap_hash($orig_mailmap_hash, $author_info);
458
459     # update the mailmap based on any check or fixes we just did,
460     # we always write even if we did not do any changes.
461     update_mailmap($mailmap_hash, $mailmap_preamble, $mailmap_file);
462
463     # read the commits names using git log, and compares and checks
464     # them against the data we have in authors.
465     read_commit_log($author_info, $mailmap_info);
466
467     # update the authors file with any changes, we always write,
468     # but we may not change anything
469     update_authors($author_info, $authors_preamble, $authors_file);
470
471     # check if we discovered new email data from the commits that
472     # we need to write back to disk.
473     add_new_mailmap_entries($mailmap_hash, $mailmap_info, $mailmap_file)
474         and update_mailmap($mailmap_hash, $mailmap_preamble,
475         $mailmap_file, $mailmap_info);
476
477     return undef;
478 }
479
480 sub main {
481     local $Data::Dumper::Sortkeys= 1;
482     my $authors_file= "AUTHORS";
483     my $mailmap_file= ".mailmap";
484     my $show_man= 0;
485     my $show_help= 0;
486
487     ## Parse options and print usage if there is a syntax error,
488     ## or if usage was explicitly requested.
489     GetOptions(
490         'help|?'                      => \$show_help,
491         'man'                         => \$show_man,
492         'authors_file|authors-file=s' => \$authors_file,
493         'mailmap_file|mailmap-file=s' => \$mailmap_file,
494     ) or pod2usage(2);
495     pod2usage(1)             if $show_help;
496     pod2usage(-verbose => 2) if $show_man;
497
498     read_and_update($authors_file, $mailmap_file);
499     return 0;    # 0 for no error - intended for exit();
500 }
501
502 exit(main()) unless caller;
503
504 1;
505 __END__
506
507 =head1 NAME
508
509 Porting/updateAUTHORS.pl - Automatically update AUTHORS and .mailmap
510 based on commit data.
511
512 =head1 SYNOPSIS
513
514 Porting/updateAUTHORS.pl
515
516  Options:
517    --help               brief help message
518    --man                full documentation
519    --authors-file=FILE  override default location of AUTHORS
520    --mailmap-file=FILE  override default location of .mailmap
521
522 =head1 OPTIONS
523
524 =over 4
525
526 =item --help
527
528 Print a brief help message and exits.
529
530 =item --man
531
532 Prints the manual page and exits.
533
534 =item --authors-file=FILE
535
536 =item --authors_file=FILE
537
538 Override the default location of the authors file, which is "AUTHORS" in
539 the current directory.
540
541 =item --mailmap-file=FILE
542
543 =item --mailmap_file=FILE
544
545 Override the default location of the mailmap file, which is ".mailmap"
546 in the current directory.
547
548 =back
549
550 =head1 DESCRIPTION
551
552 This program will automatically manage updates to the AUTHORS file and
553 .mailmap file based on the data in our commits and the data in the files
554 themselves. It uses no other sources of data. Expects to be run from
555 the root a git repo of perl.
556
557 In simple, execute the script and it will either die with a helpful
558 message or it will update the files as necessary, possibly not at all if
559 there is no need to do so. Note it will actually rewrite the files at
560 least once, but it may not actually make any changes to their content.
561 Thus to use the script is currently required that the files are
562 modifiable.
563
564 Review the changes it makes to make sure they are sane. If they are
565 commit. If they are not then update the AUTHORS or .mailmap files as is
566 appropriate and run the tool again. Typically you shouldn't need to do
567 either unless you are changing the default name or email for a user. For
568 instance if a person currently listed in the AUTHORS file whishes to
569 change their preferred name or email then change it in the AUTHORS file
570 and run the script again. I am not sure when you might need to directly
571 modify .mailmap, usually modifying the AUTHORS file should suffice.
572
573 =head1 FUNCTIONS
574
575 Note that the file can also be used as a package. If you require the
576 file then you can access the functions located within the package
577 C<Porting::updateAUTHORS>. These are as follows:
578
579 =over 4
580
581 =item add_new_mailmap_entries($mailmap_hash, $mailmap_info, $mailmap_file)
582
583 If any additions were identified while reading the commits this will
584 inject them into the mailmap_hash so they can be written out. Returns a
585 count of additions found.
586
587 =item check_fix_mailmap_hash($mailmap_hash, $authors_info)
588
589 Analyzes the data contained the in the .mailmap file and applies any
590 automated fixes which are required and which it can automatically
591 perform. Returns a hash of adjusted entries and a hash with additional
592 metadata about the mailmap entries.
593
594 =item main()
595
596 This implements the command line version of this module, handle command
597 line options, etc.
598
599 =item merge_mailmap_with_AUTHORS_and_checkAUTHORS_data
600
601 This is a utility function that combines data from this tool with data
602 contained in F<Porting/checkAUTHORS.pl> it is not used directly, but was
603 used to cleanup and generate the current version of the .mailmap file.
604
605 =item parse_mailmap_hash($mailmap_hash)
606
607 Takes a mailmap_hash and parses it and returns it as an array of array
608 records with the contents:
609
610     [ $preferred_name, $preferred_email,
611       $other_name, $other_email,
612       $line_num ]
613
614 =item read_and_update($authors_file, $mailmap_file)
615
616 Wraps the other functions in this library and implements the logic and
617 intent of this tool. Takes two arguments, the authors file name, and the
618 mailmap file name. Returns nothing but may modify the AUTHORS file
619 or the .mailmap file. Requires that both files are editable.
620
621 =item read_commit_log($authors_info, $mailmap_info)
622
623 Read the commit log and find any new names it contains.
624
625 =item read_authors($authors_file)
626
627 Read the AUTHORS file and return data about it.
628
629 =item read_mailmap($mailmap_file)
630
631 Read the .mailmap file and return data about it.
632
633 =item update_authors($authors_info, $authors_preamble, $authors_file)
634
635 Write out an updated AUTHORS file. This is done atomically
636 using a rename, we will not leave a half modified file in
637 the repo.
638
639 =item update_mailmap($mm_hash, $mm_preamble, $mailmap_file, $mm_info)
640
641 Write out an updated .mailmap file. This is done atomically
642 using a rename, we will not leave a half modified file in
643 the repo.
644
645 =back
646
647 =head1 TODO
648
649 More documentation and testing.
650
651 =head1 SEE ALSO
652
653 F<Porting/checkAUTHORS.pl>
654
655 =head1 AUTHOR
656
657 Yves Orton <demerphq@gmail.com>
658
659 =cut