This is a live mirror of the Perl 5 development currently hosted at https://github.com/perl/perl5
perldelta: Regex sets are no longer experimental
[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     binmode $out_fh;
161     foreach my $line (@$authors_preamble) {
162         print $out_fh encode_utf8($line), "\n"
163             or die "Failed to print to '$authors_file_new': $!";
164     }
165     foreach my $author (_sorted_hash_keys($author_info->{"lines"})) {
166         next if $author =~ /^unknown/;
167         if ($author =~ s/\s*<unknown>\z//) {
168             next if $author =~ /^\w+$/;
169         }
170         print $out_fh encode_utf8($author), "\n"
171             or die "Failed to print to '$authors_file_new': $!";
172     }
173     close $out_fh
174         or die "Failed to close '$authors_file_new': $!";
175     rename $authors_file_new, $authors_file
176         or die "Failed to rename '$authors_file_new' to '$authors_file':$!";
177     return 1;    # ok
178 }
179
180 sub read_mailmap {
181     my ($mailmap_file)= @_;
182     $mailmap_file ||= ".mailmap";
183
184     open my $in, "<", $mailmap_file
185         or die "Failed to read '$mailmap_file': $!";
186     my %mailmap_hash;
187     my @mailmap_preamble;
188     my $line_num= 0;
189     while (defined(my $line= <$in>)) {
190         ++$line_num;
191         next unless $line =~ /\S/;
192         chomp($line);
193         $line= decode_utf8($line);
194         if ($line =~ /^#/) {
195             if (!keys %mailmap_hash) {
196                 push @mailmap_preamble, $line;
197             }
198             else {
199                 die encode_utf8 "Not expecting comments after header ",
200                     "finished at line $line_num!\nLine: $line\n";
201             }
202         }
203         else {
204             $mailmap_hash{$line}= $line_num;
205         }
206     }
207     close $in;
208     return \%mailmap_hash, \@mailmap_preamble;
209 }
210
211 # this can be used to extract data from the checkAUTHORS data
212 sub merge_mailmap_with_AUTHORS_and_checkAUTHORS_data {
213     my ($mailmap_hash, $author_info)= @_;
214     require 'Porting/checkAUTHORS.pl' or die "No authors?";
215     my ($map, $preferred_email_or_github)=
216         Porting::checkAUTHORS::generate_known_author_map();
217
218     foreach my $old (sort keys %$preferred_email_or_github) {
219         my $new= $preferred_email_or_github->{$old};
220         next if $old !~ /\@/ or $new !~ /\@/ or $new eq $old;
221         my $name= $author_info->{"email2name"}{$new};
222         if ($name) {
223             my $line= "$name <$new> <$old>";
224             $mailmap_hash->{$line}++;
225         }
226     }
227     return 1;    # ok
228 }
229
230 sub _sorted_hash_keys {
231     my ($hash)= @_;
232     my @sorted= sort { lc($a) cmp lc($b) || $a cmp $b } keys %$hash;
233     return @sorted;
234 }
235
236 sub update_mailmap {
237     my ($mailmap_hash, $mailmap_preamble, $mailmap_file)= @_;
238     $mailmap_file ||= ".mailmap";
239
240     my $mailmap_file_new= $mailmap_file . "_new";
241     open my $out, ">", $mailmap_file_new
242         or die "Failed to write '$mailmap_file_new':$!";
243     binmode $out;
244     foreach my $line (@$mailmap_preamble, _sorted_hash_keys($mailmap_hash),) {
245         print $out encode_utf8($line), "\n"
246             or die "Failed to print to '$mailmap_file': $!";
247     }
248     close $out;
249     rename $mailmap_file_new, $mailmap_file
250         or die "Failed to rename '$mailmap_file_new' to '$mailmap_file':$!";
251     return 1;    # ok
252 }
253
254 sub parse_mailmap_hash {
255     my ($mailmap_hash)= @_;
256     my @recs;
257     foreach my $line (sort keys %$mailmap_hash) {
258         my $line_num= $mailmap_hash->{$line};
259         $line =~ /^ \s* (?: ( [^<>]*? ) \s+ )? <([^<>]*)>
260                 (?: \s+ (?: ( [^<>]*? ) \s+ )? <([^<>]*)> )? \s* \z /x
261             or die encode_utf8 "Failed to parse line num $line_num: '$line'";
262         if (!$1 or !$2) {
263             die encode_utf8 "Both preferred name and email are mandatory ",
264                 "in line num $line_num: '$line'";
265         }
266
267         # [ preferred_name, preferred_email, other_name, other_email ]
268         push @recs, [ $1, $2, $3, $4, $line_num ];
269     }
270     return \@recs;
271 }
272
273 sub _safe_set_key {
274     my ($hash, $root_key, $key, $val, $pretty_name)= @_;
275     $hash->{$root_key}{$key} //= $val;
276     my $prev= $hash->{$root_key}{$key};
277     if ($prev ne $val) {
278         die encode_utf8 "Collision on mapping $root_key: "
279             . " '$key' maps to '$prev' and '$val'\n";
280     }
281 }
282
283 my $O2P= "other2preferred";
284 my $O2PN= "other2preferred_name";
285 my $O2PE= "other2preferred_email";
286 my $P2O= "preferred2other";
287 my $N2P= "name2preferred";
288 my $E2P= "email2preferred";
289
290 my $blurb= "";    # FIXME - replace with a nice message
291
292 sub _check_name_mailmap {
293     my ($mailmap_info, $auth_name, $raw_name, $commit_info, $descr)= @_;
294     my $name= $auth_name;
295     $name =~ s/<([^<>]+)>/<\L$1\E>/
296         or $name =~ s/(\s)(\@\w+)\z/$1<\L$2\E>/
297         or $name .= " <unknown>";
298
299     $name =~ s/\s+/ /g;
300
301     if (!$mailmap_info->{$P2O}{$name}) {
302         warn encode_utf8 sprintf "Unknown %s '%s' in commit %s '%s'\n%s",
303             $descr,
304             $name, $commit_info->{"abbrev_hash"},
305             $commit_info->{"commit_subject"},
306             $blurb;
307         $mailmap_info->{add}{"$name $raw_name"}++;
308         return 0;
309     }
310     elsif (!$mailmap_info->{$P2O}{$name}{$raw_name}) {
311         $mailmap_info->{add}{"$name $raw_name"}++;
312     }
313     return 1;
314 }
315
316 sub check_fix_mailmap_hash {
317     my ($mailmap_hash, $authors_info)= @_;
318     my $parsed= parse_mailmap_hash($mailmap_hash);
319     my @fixed;
320     my %seen_map;
321     my %pref_groups;
322
323     # first pass through the data, do any conversions, eg, LC
324     # the email address, decode any MIME-Header style email addresses.
325     # We also correct any preferred name entries so they match what
326     # we already have in AUTHORS, and check that there aren't collisions
327     # or other issues in the data.
328     foreach my $rec (@$parsed) {
329         my ($pname, $pemail, $oname, $oemail, $line_num)= @$rec;
330         $pemail= lc($pemail);
331         $oemail= lc($oemail) if defined $oemail;
332         if ($pname =~ /=\?UTF-8\?/) {
333             $pname= decode("MIME-Header", $pname);
334         }
335         my $auth_email= $authors_info->{"name2email"}{$pname};
336         if ($auth_email) {
337             ## this name exists in authors, so use its email data for pemail
338             $pemail= $auth_email;
339         }
340         my $auth_name= $authors_info->{"email2name"}{$pemail};
341         if ($auth_name) {
342             ## this email exists in authors, so use its name data for pname
343             $pname= $auth_name;
344         }
345
346         # neither name nor email exist in authors.
347         if ($pname ne "unknown") {
348             if (my $email= $seen_map{"name"}{$pname}) {
349                 ## we have seen this pname before, check the pemail
350                 ## is consistent
351                 if ($email ne $pemail) {
352                     warn encode_utf8 "Inconsistent emails for name '$pname'"
353                         . " at line num $line_num: keeping '$email',"
354                         . " ignoring '$pemail'\n";
355                     $pemail= $email;
356                 }
357             }
358             else {
359                 $seen_map{"name"}{$pname}= $pemail;
360             }
361         }
362         if ($pemail ne "unknown") {
363             if (my $name= $seen_map{"email"}{$pemail}) {
364                 ## we have seen this preferred_email before, check the preferred_name
365                 ## is consistent
366                 if ($name ne $pname) {
367                     warn encode_utf8 "Inconsistent name for email '$pemail'"
368                         . " at line num $line_num: keeping '$name', ignoring"
369                         . " '$pname'\n";
370                     $pname= $name;
371                 }
372             }
373             else {
374                 $seen_map{"email"}{$pemail}= $pname;
375             }
376         }
377
378         # Build an index of "preferred name/email" to other-email, other name
379         # we use this later to remove redundant entries missing a name.
380         $pref_groups{"$pname $pemail"}{$oemail}{ $oname || "" }=
381             [ $pname, $pemail, $oname, $oemail, $line_num ];
382     }
383
384     # this removes entries like
385     # Joe <blogs> <whatever>
386     # where there is a corresponding
387     # Joe <blogs> Joe X <blogs>
388     foreach my $pref (_sorted_hash_keys(\%pref_groups)) {
389         my $entries= $pref_groups{$pref};
390         foreach my $email (_sorted_hash_keys($entries)) {
391             my @names= _sorted_hash_keys($entries->{$email});
392             if ($names[0] eq "" and @names > 1) {
393                 shift @names;
394             }
395             foreach my $name (@names) {
396                 push @fixed, $entries->{$email}{$name};
397             }
398         }
399     }
400
401     # final pass through the dataset, build up a database
402     # we will use later for checks and updates, and reconstruct
403     # the canonical entries.
404     my $new_mailmap_hash= {};
405     my $mailmap_info=     {};
406     foreach my $rec (@fixed) {
407         my ($pname, $pemail, $oname, $oemail, $line_num)= @$rec;
408         my $preferred= "$pname <$pemail>";
409         my $other;
410         if (defined $oemail) {
411             $other= $oname ? "$oname <$oemail>" : "<$oemail>";
412         }
413         if ($other and $other ne "<unknown>") {
414             _safe_set_key($mailmap_info, $O2P,  $other, $preferred);
415             _safe_set_key($mailmap_info, $O2PN, $other, $pname);
416             _safe_set_key($mailmap_info, $O2PE, $other, $pemail);
417         }
418         $mailmap_info->{$P2O}{$preferred}{$other}++;
419         if ($pname ne "unknown") {
420             _safe_set_key($mailmap_info, $N2P, $pname, $preferred);
421         }
422         if ($pemail ne "unknown") {
423             _safe_set_key($mailmap_info, $E2P, $pemail, $preferred);
424         }
425         my $line= $preferred;
426         $line .= " $other" if $other;
427         $new_mailmap_hash->{$line}= $line_num;
428     }
429     return ($new_mailmap_hash, $mailmap_info);
430 }
431
432 sub add_new_mailmap_entries {
433     my ($mailmap_hash, $mailmap_info, $mailmap_file)= @_;
434
435     my $mailmap_add= $mailmap_info->{add}
436         or return 0;
437
438     my $num= 0;
439     for my $new (sort keys %$mailmap_add) {
440         !$mailmap_hash->{$new}++ or next;
441         warn encode_utf8 "Updating '$mailmap_file' with: $new\n";
442         $num++;
443     }
444     return $num;
445 }
446
447 sub read_and_update {
448     my ($authors_file, $mailmap_file)= @_;
449
450     # read the authors file and extract the info it contains
451     my ($author_info, $authors_preamble)= read_authors($authors_file);
452
453     # read the mailmap file.
454     my ($orig_mailmap_hash, $mailmap_preamble)= read_mailmap($mailmap_file);
455
456     # check and possibly fix the mailmap data, and build a set of precomputed
457     # datasets to work with it.
458     my ($mailmap_hash, $mailmap_info)=
459         check_fix_mailmap_hash($orig_mailmap_hash, $author_info);
460
461     # update the mailmap based on any check or fixes we just did,
462     # we always write even if we did not do any changes.
463     update_mailmap($mailmap_hash, $mailmap_preamble, $mailmap_file);
464
465     # read the commits names using git log, and compares and checks
466     # them against the data we have in authors.
467     read_commit_log($author_info, $mailmap_info);
468
469     # update the authors file with any changes, we always write,
470     # but we may not change anything
471     update_authors($author_info, $authors_preamble, $authors_file);
472
473     # check if we discovered new email data from the commits that
474     # we need to write back to disk.
475     add_new_mailmap_entries($mailmap_hash, $mailmap_info, $mailmap_file)
476         and update_mailmap($mailmap_hash, $mailmap_preamble,
477         $mailmap_file, $mailmap_info);
478
479     return undef;
480 }
481
482 sub main {
483     local $Data::Dumper::Sortkeys= 1;
484     my $authors_file= "AUTHORS";
485     my $mailmap_file= ".mailmap";
486     my $show_man= 0;
487     my $show_help= 0;
488
489     ## Parse options and print usage if there is a syntax error,
490     ## or if usage was explicitly requested.
491     GetOptions(
492         'help|?'                      => \$show_help,
493         'man'                         => \$show_man,
494         'authors_file|authors-file=s' => \$authors_file,
495         'mailmap_file|mailmap-file=s' => \$mailmap_file,
496     ) or pod2usage(2);
497     pod2usage(1)             if $show_help;
498     pod2usage(-verbose => 2) if $show_man;
499
500     read_and_update($authors_file, $mailmap_file);
501     return 0;    # 0 for no error - intended for exit();
502 }
503
504 exit(main()) unless caller;
505
506 1;
507 __END__
508
509 =head1 NAME
510
511 Porting/updateAUTHORS.pl - Automatically update AUTHORS and .mailmap
512 based on commit data.
513
514 =head1 SYNOPSIS
515
516 Porting/updateAUTHORS.pl
517
518  Options:
519    --help               brief help message
520    --man                full documentation
521    --authors-file=FILE  override default location of AUTHORS
522    --mailmap-file=FILE  override default location of .mailmap
523
524 =head1 OPTIONS
525
526 =over 4
527
528 =item --help
529
530 Print a brief help message and exits.
531
532 =item --man
533
534 Prints the manual page and exits.
535
536 =item --authors-file=FILE
537
538 =item --authors_file=FILE
539
540 Override the default location of the authors file, which is "AUTHORS" in
541 the current directory.
542
543 =item --mailmap-file=FILE
544
545 =item --mailmap_file=FILE
546
547 Override the default location of the mailmap file, which is ".mailmap"
548 in the current directory.
549
550 =back
551
552 =head1 DESCRIPTION
553
554 This program will automatically manage updates to the AUTHORS file and
555 .mailmap file based on the data in our commits and the data in the files
556 themselves. It uses no other sources of data. Expects to be run from
557 the root a git repo of perl.
558
559 In simple, execute the script and it will either die with a helpful
560 message or it will update the files as necessary, possibly not at all if
561 there is no need to do so. Note it will actually rewrite the files at
562 least once, but it may not actually make any changes to their content.
563 Thus to use the script is currently required that the files are
564 modifiable.
565
566 Review the changes it makes to make sure they are sane. If they are
567 commit. If they are not then update the AUTHORS or .mailmap files as is
568 appropriate and run the tool again. Typically you shouldn't need to do
569 either unless you are changing the default name or email for a user. For
570 instance if a person currently listed in the AUTHORS file whishes to
571 change their preferred name or email then change it in the AUTHORS file
572 and run the script again. I am not sure when you might need to directly
573 modify .mailmap, usually modifying the AUTHORS file should suffice.
574
575 =head1 FUNCTIONS
576
577 Note that the file can also be used as a package. If you require the
578 file then you can access the functions located within the package
579 C<Porting::updateAUTHORS>. These are as follows:
580
581 =over 4
582
583 =item add_new_mailmap_entries($mailmap_hash, $mailmap_info, $mailmap_file)
584
585 If any additions were identified while reading the commits this will
586 inject them into the mailmap_hash so they can be written out. Returns a
587 count of additions found.
588
589 =item check_fix_mailmap_hash($mailmap_hash, $authors_info)
590
591 Analyzes the data contained the in the .mailmap file and applies any
592 automated fixes which are required and which it can automatically
593 perform. Returns a hash of adjusted entries and a hash with additional
594 metadata about the mailmap entries.
595
596 =item main()
597
598 This implements the command line version of this module, handle command
599 line options, etc.
600
601 =item merge_mailmap_with_AUTHORS_and_checkAUTHORS_data
602
603 This is a utility function that combines data from this tool with data
604 contained in F<Porting/checkAUTHORS.pl> it is not used directly, but was
605 used to cleanup and generate the current version of the .mailmap file.
606
607 =item parse_mailmap_hash($mailmap_hash)
608
609 Takes a mailmap_hash and parses it and returns it as an array of array
610 records with the contents:
611
612     [ $preferred_name, $preferred_email,
613       $other_name, $other_email,
614       $line_num ]
615
616 =item read_and_update($authors_file, $mailmap_file)
617
618 Wraps the other functions in this library and implements the logic and
619 intent of this tool. Takes two arguments, the authors file name, and the
620 mailmap file name. Returns nothing but may modify the AUTHORS file
621 or the .mailmap file. Requires that both files are editable.
622
623 =item read_commit_log($authors_info, $mailmap_info)
624
625 Read the commit log and find any new names it contains.
626
627 =item read_authors($authors_file)
628
629 Read the AUTHORS file and return data about it.
630
631 =item read_mailmap($mailmap_file)
632
633 Read the .mailmap file and return data about it.
634
635 =item update_authors($authors_info, $authors_preamble, $authors_file)
636
637 Write out an updated AUTHORS file. This is done atomically
638 using a rename, we will not leave a half modified file in
639 the repo.
640
641 =item update_mailmap($mm_hash, $mm_preamble, $mailmap_file, $mm_info)
642
643 Write out an updated .mailmap file. This is done atomically
644 using a rename, we will not leave a half modified file in
645 the repo.
646
647 =back
648
649 =head1 TODO
650
651 More documentation and testing.
652
653 =head1 SEE ALSO
654
655 F<Porting/checkAUTHORS.pl>
656
657 =head1 AUTHOR
658
659 Yves Orton <demerphq@gmail.com>
660
661 =cut