This is a live mirror of the Perl 5 development currently hosted at https://github.com/perl/perl5
Clarify the pod.lst instruction in rm guide
[perl5.git] / Porting / core-cpan-diff
1 #!/usr/bin/env perl
2
3 # core-cpan-diff: Compare CPAN modules with their equivalent in core
4
5 # Originally based on App::DualLivedDiff by Steffen Mueller.
6
7 use strict;
8 use warnings;
9
10 use 5.010;
11
12 use Getopt::Long;
13 use File::Basename ();
14 use File::Copy     ();
15 use File::Temp     ();
16 use File::Path     ();
17 use File::Spec::Functions;
18 use Archive::Extract;
19 use IO::Uncompress::Gunzip ();
20 use File::Compare          ();
21 use ExtUtils::Manifest;
22 use ExtUtils::MakeMaker ();
23 use HTTP::Tiny;
24
25 BEGIN { die "Must be run from root of perl source tree\n" unless -d 'Porting' }
26 use lib 'Porting';
27 use Maintainers ();
28
29 # if running from blead, we may be doing -Ilib, which means when we
30 # 'chdir /tmp/foo', Archive::Extract may not find Archive::Tar etc.
31 # So preload the things we need, and tell it to check %INC first:
32
33 use Archive::Tar;
34 use IPC::Open3;
35 use IO::Select;
36 $Module::Load::Conditional::CHECK_INC_HASH = 1;
37
38 # stop Archive::Extract whinging about lack of Archive::Zip
39 $Archive::Extract::WARN = 0;
40
41 # where, under the cache dir, to download tarballs to
42 use constant SRC_DIR => 'tarballs';
43
44 # where, under the cache dir, to untar stuff to
45 use constant UNTAR_DIR => 'untarred';
46
47 use constant DIFF_CMD => 'diff';
48 use constant WGET_CMD => 'wget';
49
50 sub usage {
51     print STDERR "\n@_\n\n" if @_;
52     print STDERR <<HERE;
53 Usage: $0 [opts] [ -d | -v | -x ] [ -a | module ... ]
54
55 -a/--all      Scan all dual-life modules.
56
57 -c/--cachedir Where to save downloaded CPAN tarball files
58               (defaults to /tmp/something/ with deletion after each run).
59
60 -d/--diff     Display file differences using diff(1), rather than just
61               listing which files have changed.
62               The diff(1) command is assumed to be in your PATH.
63
64 --diffopts    Options to pass to the diff command. Defaults to '-u'.
65
66 -f|force      Force download from CPAN of new 02packages.details.txt file
67               (with --crosscheck only).
68
69 -m|mirror     Preferred CPAN mirror URI (http:// or file:///)
70               (Local mirror must be a complete mirror, not minicpan)
71
72 -o/--output   File name to write output to (defaults to STDOUT).
73
74 -r/--reverse  Reverses the diff (perl to CPAN).
75
76 -u/--upstream only print modules with the given upstream (defaults to all)
77
78 -v/--verbose  List the fate of *all* files in the tarball, not just those
79               that differ or are missing.
80
81 -x|crosscheck List the distributions whose current CPAN version differs from
82               that in blead (i.e. the DISTRIBUTION field in Maintainers.pl).
83
84 By default (i.e. without the --crosscheck option),  for each listed module
85 (or with -a, all CPAN modules listed in Maintainers.pl), grab the tarball
86 from CPAN associated with that module, and compare the files in it with
87 those in the perl source tree.
88
89 Must be run from the root of the perl source tree.
90 Module names must match the keys of %Modules in Maintainers.pl.
91 HERE
92     exit(1);
93 }
94
95 sub run {
96     my $scan_all;
97     my $diff_opts;
98     my $reverse = 0;
99     my @wanted_upstreams;
100     my $cache_dir;
101     my $mirror_url = "http://www.cpan.org/";
102     my $use_diff;
103     my $output_file;
104     my $verbose;
105     my $force;
106     my $do_crosscheck;
107
108     GetOptions(
109         'a|all'         => \$scan_all,
110         'c|cachedir=s'  => \$cache_dir,
111         'd|diff'        => \$use_diff,
112         'diffopts:s'    => \$diff_opts,
113         'f|force'       => \$force,
114         'h|help'        => \&usage,
115         'm|mirror=s'    => \$mirror_url,
116         'o|output=s'    => \$output_file,
117         'r|reverse'     => \$reverse,
118         'u|upstream=s@' => \@wanted_upstreams,
119         'v|verbose'     => \$verbose,
120         'x|crosscheck'  => \$do_crosscheck,
121     ) or usage;
122
123     my @modules;
124
125     usage("Cannot mix -a with module list") if $scan_all && @ARGV;
126
127     if ($do_crosscheck) {
128         usage("can't use -r, -d, --diffopts, -v with --crosscheck")
129           if ( $reverse || $use_diff || $diff_opts || $verbose );
130     }
131     else {
132         $diff_opts = '-u -b' unless defined $diff_opts;
133         usage("can't use -f without --crosscheck") if $force;
134     }
135
136     @modules =
137       $scan_all
138       ? grep $Maintainers::Modules{$_}{CPAN},
139       ( sort { lc $a cmp lc $b } keys %Maintainers::Modules )
140       : @ARGV;
141     usage("No modules specified") unless @modules;
142
143     my $outfh;
144     if ( defined $output_file ) {
145         open $outfh, '>', $output_file
146           or die "ERROR: could not open file '$output_file' for writing: $!\n";
147     }
148     else {
149         open $outfh, ">&STDOUT"
150           or die "ERROR: can't dup STDOUT: $!\n";
151     }
152
153     if ( defined $cache_dir ) {
154         die "ERROR: no such directory: '$cache_dir'\n" unless -d $cache_dir;
155     }
156     else {
157         $cache_dir = File::Temp::tempdir( CLEANUP => 1 );
158     }
159
160     $mirror_url .= "/" unless substr( $mirror_url, -1 ) eq "/";
161     my $test_file = "modules/07mirror.yml";
162     my_getstore(
163         cpan_url( $mirror_url, $test_file ),
164         catfile( $cache_dir, $test_file )
165     ) or die "ERROR: not a CPAN mirror '$mirror_url'\n";
166
167     if ($do_crosscheck) {
168         do_crosscheck( $outfh, $cache_dir, $mirror_url, $force, \@modules );
169     }
170     else {
171         do_compare(
172             \@modules,  $outfh,      $output_file,
173             $cache_dir, $mirror_url, $verbose,
174             $use_diff,  $reverse,    $diff_opts,
175             \@wanted_upstreams
176         );
177     }
178 }
179
180 # construct a CPAN url
181
182 sub cpan_url {
183     my ( $mirror_url, @path ) = @_;
184     return $mirror_url unless @path;
185     my $cpan_path = join( "/", map { split "/", $_ } @path );
186     $cpan_path =~ s{\A/}{};    # remove leading slash since url has one trailing
187     return $mirror_url . $cpan_path;
188 }
189
190 # compare a list of modules against their CPAN equivalents
191
192 sub do_compare {
193     my (
194         $modules,    $outfh,   $output_file, $cache_dir,
195         $mirror_url, $verbose, $use_diff,    $reverse,
196         $diff_opts,  $wanted_upstreams
197     ) = @_;
198
199     # first, make sure we have a directory where they can all be untarred,
200     # and if its a permanent directory, clear any previous content
201     my $untar_dir = catdir( $cache_dir, UNTAR_DIR );
202     my $src_dir   = catdir( $cache_dir, SRC_DIR );
203     for my $d ( $src_dir, $untar_dir ) {
204         next if -d $d;
205         mkdir $d or die "mkdir $d: $!\n";
206     }
207
208     my %ignorable = map { ( $_ => 1 ) } @Maintainers::IGNORABLE;
209
210     my %seen_dist;
211     for my $module (@$modules) {
212         warn "Processing $module ...\n" if defined $output_file;
213
214         my $m = $Maintainers::Modules{$module}
215           or die "ERROR: No such module in Maintainers.pl: '$module'\n";
216
217         unless ( $m->{CPAN} ) {
218             print $outfh "WARNING: $module is not dual-life; skipping\n";
219             next;
220         }
221
222         my $dist = $m->{DISTRIBUTION};
223         die "ERROR: $module has no DISTRIBUTION entry\n" unless defined $dist;
224
225         if ( $seen_dist{$dist}++ ) {
226             warn "WARNING: duplicate entry for $dist in $module\n";
227         }
228
229         my $upstream = $m->{UPSTREAM} || 'UNKNOWN';
230         next if @$wanted_upstreams and !( $upstream ~~ $wanted_upstreams );
231
232         print $outfh "\n$module - "
233           . $Maintainers::Modules{$module}->{DISTRIBUTION} . "\n";
234         print $outfh "  upstream is: "
235           . ( $m->{UPSTREAM} || 'UNKNOWN!' ) . "\n";
236
237         my $cpan_dir;
238         eval {
239             $cpan_dir =
240               get_distribution( $src_dir, $mirror_url, $untar_dir, $module,
241                 $dist );
242         };
243         if ($@) {
244             print $outfh "  ", $@;
245             print $outfh "  (skipping)\n";
246             next;
247         }
248
249         my @perl_files = Maintainers::get_module_files($module);
250
251         my $manifest = catfile( $cpan_dir, 'MANIFEST' );
252         die "ERROR: no such file: $manifest\n" unless -f $manifest;
253
254         my $cpan_files = ExtUtils::Manifest::maniread($manifest);
255         my @cpan_files = sort keys %$cpan_files;
256
257         ( my $main_pm = $module ) =~ s{::}{/}g;
258         $main_pm .= ".pm";
259
260         my ( $excluded, $map, $customized ) =
261           get_map( $m, $module, \@perl_files );
262
263         my %perl_unseen;
264         @perl_unseen{@perl_files} = ();
265         my %perl_files = %perl_unseen;
266
267         foreach my $cpan_file (@cpan_files) {
268             my $mapped_file =
269               cpan_to_perl( $excluded, $map, $customized, $cpan_file );
270             unless ( defined $mapped_file ) {
271                 print $outfh "  Excluded:  $cpan_file\n" if $verbose;
272                 next;
273             }
274
275             if ( exists $perl_files{$mapped_file} ) {
276                 delete $perl_unseen{$mapped_file};
277             }
278             else {
279
280                 # some CPAN files foo are stored in core as foo.packed,
281                 # which are then unpacked by 'make test_prep'
282                 my $packed_file = "$mapped_file.packed";
283                 if ( exists $perl_files{$packed_file} ) {
284                     if ( !-f $mapped_file and -f $packed_file ) {
285                         print $outfh <<EOF;
286 WARNING: $mapped_file not found, but .packed variant exists.
287 Perhaps you need to run 'make test_prep'?
288 EOF
289                         next;
290                     }
291                     delete $perl_unseen{$packed_file};
292                 }
293                 else {
294                     if ( $ignorable{$cpan_file} ) {
295                         print $outfh "  Ignored:   $cpan_file\n" if $verbose;
296                         next;
297                     }
298
299                     unless ($use_diff) {
300                         print $outfh "  CPAN only: $cpan_file",
301                           ( $cpan_file eq $mapped_file )
302                           ? "\n"
303                           : " (expected $mapped_file)\n";
304                     }
305                     next;
306                 }
307             }
308
309             my $abs_cpan_file = catfile( $cpan_dir, $cpan_file );
310
311             # should never happen
312             die "ERROR: can't find file $abs_cpan_file\n"
313               unless -f $abs_cpan_file;
314
315             # might happen if the FILES entry in Maintainers.pl is wrong
316             unless ( -f $mapped_file ) {
317                 print $outfh "WARNING: perl file not found: $mapped_file\n";
318                 next;
319             }
320
321             my $relative_mapped_file = relatively_mapped($mapped_file);
322
323             my $different =
324               file_diff( $outfh, $abs_cpan_file, $mapped_file, $reverse,
325                 $diff_opts );
326             if ( $different && customized( $m, $relative_mapped_file ) ) {
327                 if (! $use_diff ) {
328                     print $outfh "  Customized for blead: $relative_mapped_file\n";
329                 }
330             }
331             elsif ($different) {
332                 if ($use_diff) {
333                     $different =~ s{^(--- |\+\+\+ )/tmp/[^/]+/}{$1}gm;
334                     print $outfh $different;
335                 }
336                 else {
337                     if ( $cpan_file eq $relative_mapped_file ) {
338                         print $outfh "  Modified:  $relative_mapped_file\n";
339                     }
340                     else {
341                         print $outfh
342                           "  Modified:  $cpan_file $relative_mapped_file\n";
343                     }
344
345                     if ( $cpan_file =~ m{\.pm\z} ) {
346                         my $pv = MM->parse_version($mapped_file)   || 'unknown';
347                         my $cv = MM->parse_version($abs_cpan_file) || 'unknown';
348                         if ( $pv ne $cv ) {
349                             print $outfh
350 "  Version mismatch in '$cpan_file':\n    $cv (cpan) vs $pv (perl)\n";
351                         }
352                     }
353
354                 }
355             }
356             elsif ( customized( $m, $relative_mapped_file ) ) {
357                 # Maintainers.pl says we customized it, but it looks the
358                 # same as CPAN so maybe we lost the customization, which
359                 # could be bad
360                 if ( $cpan_file eq $relative_mapped_file ) {
361                     print $outfh "  Blead customization missing: $cpan_file\n";
362                 }
363                 else {
364                     print $outfh
365                       "  Blead customization missing: $cpan_file $relative_mapped_file\n";
366                 }
367             }
368             elsif ($verbose) {
369                 if ( $cpan_file eq $relative_mapped_file ) {
370                     print $outfh "  Unchanged: $cpan_file\n";
371                 }
372                 else {
373                     print $outfh
374                       "  Unchanged: $cpan_file $relative_mapped_file\n";
375                 }
376             }
377         }
378         for ( sort keys %perl_unseen ) {
379             my $relative_mapped_file = relatively_mapped($_);
380             if ( customized( $m, $relative_mapped_file ) ) {
381                 print $outfh "  Customized for blead: $_\n";
382             }
383             else {
384                 print $outfh "  Perl only: $_\n" unless $use_diff;
385             }
386         }
387     }
388 }
389
390 sub relatively_mapped {
391     my $relative = shift;
392     $relative =~ s/^(cpan|dist|ext)\/.*?\///;
393     return $relative;
394 }
395
396 # given FooBar-1.23_45.tar.gz, return FooBar
397
398 sub distro_base {
399     my $d = shift;
400     $d =~ s/\.tar\.gz$//;
401     $d =~ s/\.gip$//;
402     $d =~ s/[\d\-_\.]+$//;
403     return $d;
404 }
405
406 # process --crosscheck action:
407 # ie list all distributions whose CPAN versions differ from that listed in
408 # Maintainers.pl
409
410 sub do_crosscheck {
411     my ( $outfh, $cache_dir, $mirror_url, $force, $modules ) = @_;
412
413     my $file         = '02packages.details.txt';
414     my $download_dir = $cache_dir || File::Temp::tempdir( CLEANUP => 1 );
415     my $path         = catfile( $download_dir, $file );
416     my $gzfile       = "$path.gz";
417
418     # grab 02packages.details.txt
419
420     my $url = cpan_url( $mirror_url, "modules/02packages.details.txt.gz" );
421
422     if ( !-f $gzfile or $force ) {
423         unlink $gzfile;
424         my_getstore( $url, $gzfile );
425     }
426     unlink $path;
427     IO::Uncompress::Gunzip::gunzip( $gzfile, $path )
428       or die
429       "ERROR: failed to ungzip $gzfile: $IO::Uncompress::Gunzip::GunzipError\n";
430
431     # suck in the data from it
432
433     open my $fh, '<', $path
434       or die "ERROR: open: $file: $!\n";
435
436     my %distros;
437     my %modules;
438
439     while (<$fh>) {
440         next if 1 .. /^$/;
441         chomp;
442         my @f = split ' ', $_;
443         if ( @f != 3 ) {
444             warn
445               "WARNING: $file:$.: line doesn't have three fields (skipping)\n";
446             next;
447         }
448         my $distro = $f[2];
449         $distro =~ s{^[A-Z]/[A-Z]{2}/}{};    # strip leading A/AB/
450         $modules{ $f[0] } = $distro;
451
452         ( my $short_distro = $distro ) =~ s{^.*/}{};
453
454         $distros{ distro_base($short_distro) }{$distro} = 1;
455     }
456
457     for my $module (@$modules) {
458         my $m = $Maintainers::Modules{$module}
459           or die "ERROR: No such module in Maintainers.pl: '$module'\n";
460
461         unless ( $m->{CPAN} ) {
462             print $outfh "\nWARNING: $module is not dual-life; skipping\n";
463             next;
464         }
465
466         # given an entry like
467         #   Foo::Bar 1.23 foo-bar-1.23.tar.gz,
468         # first compare the module name against Foo::Bar, and failing that,
469         # against foo-bar
470
471         my $pdist = $m->{DISTRIBUTION};
472         die "ERROR: $module has no DISTRIBUTION entry\n" unless defined $pdist;
473
474         my $cdist = $modules{$module};
475         ( my $short_pdist = $pdist ) =~ s{^.*/}{};
476
477         unless ( defined $cdist ) {
478             my $d = $distros{ distro_base($short_pdist) };
479             unless ( defined $d ) {
480                 print $outfh "\n$module: Can't determine current CPAN entry\n";
481                 next;
482             }
483             if ( keys %$d > 1 ) {
484                 print $outfh
485                   "\n$module: (found more than one CPAN candidate):\n";
486                 print $outfh "    perl: $pdist\n";
487                 print $outfh "    CPAN: $_\n" for sort keys %$d;
488                 next;
489             }
490             $cdist = ( keys %$d )[0];
491         }
492
493         if ( $cdist ne $pdist ) {
494             print $outfh "\n$module:\n    Perl: $pdist\n    CPAN: $cdist\n";
495         }
496     }
497 }
498
499 # get the EXCLUDED and MAP entries for this module, or
500 # make up defauts if they don't exist
501
502 sub get_map {
503     my ( $m, $module_name, $perl_files ) = @_;
504
505     my ( $excluded, $map, $customized ) = @$m{qw(EXCLUDED MAP CUSTOMIZED)};
506
507     $excluded   ||= [];
508     $customized ||= [];
509
510     return $excluded, $map, $customized if $map;
511
512     # all files under ext/foo-bar (plus maybe some under t/lib)???
513
514     my $ext;
515     for (@$perl_files) {
516         if (m{^((?:ext|dist|cpan)/[^/]+/)}) {
517             if ( defined $ext and $ext ne $1 ) {
518
519                 # more than one ext/$ext/
520                 undef $ext;
521                 last;
522             }
523             $ext = $1;
524         }
525         elsif (m{^t/lib/}) {
526             next;
527         }
528         else {
529             undef $ext;
530             last;
531         }
532     }
533
534     if ( defined $ext ) {
535         $map = { '' => $ext },;
536     }
537     else {
538         ( my $base = $module_name ) =~ s{::}{/}g;
539         $base = "lib/$base";
540         $map  = {
541             'lib/' => 'lib/',
542             ''     => "$base/",
543         };
544     }
545     return $excluded, $map, $customized;
546 }
547
548 # Given an exclude list and a mapping hash, convert a CPAN filename
549 # (eg 't/bar.t') to the equivalent perl filename (eg 'lib/Foo/Bar/t/bar.t').
550 # Returns an empty list for an excluded file
551
552 sub cpan_to_perl {
553     my ( $excluded, $map, $customized, $cpan_file ) = @_;
554
555     for my $exclude (@$excluded) {
556         next if $exclude ~~ $customized;
557
558         # may be a simple string to match exactly, or a pattern
559         if ( ref $exclude ) {
560             return if $cpan_file =~ $exclude;
561         }
562         else {
563             return if $cpan_file eq $exclude;
564         }
565     }
566
567     my $perl_file = $cpan_file;
568
569     # try longest prefix first, then alphabetically on tie-break
570     for
571       my $prefix ( sort { length($b) <=> length($a) || $a cmp $b } keys %$map )
572     {
573         last if $perl_file =~ s/^\Q$prefix/$map->{$prefix}/;
574     }
575     return $perl_file;
576 }
577
578 # fetch a file from a URL and store it in a file given by a filename
579
580 sub my_getstore {
581     my ( $url, $file ) = @_;
582     File::Path::mkpath( File::Basename::dirname($file) );
583     if ( $url =~ qr{\Afile://(?:localhost)?/} ) {
584         ( my $local_path = $url ) =~ s{\Afile://(?:localhost)?}{};
585         File::Copy::copy( $local_path, $file );
586     } else {
587         my $http = HTTP::Tiny->new;
588         my $response = $http->mirror($url, $file);
589         return $response->{success};
590     }
591 }
592
593 # download and unpack a distribution
594 # Returns the full pathname of the extracted directory
595 # (eg '/tmp/XYZ/Foo_bar-1.23')
596
597 # cache_dir:  where to download the .tar.gz file to
598 # mirror_url: CPAN mirror to download from
599 # untar_dir:  where to untar or unzup the file
600 # module:     name of module
601 # dist:       name of the distribution
602
603 sub get_distribution {
604     my ( $src_dir, $mirror_url, $untar_dir, $module, $dist ) = @_;
605
606     $dist =~ m{.+/([^/]+)$}
607       or die
608       "ERROR: $module: invalid DISTRIBUTION name (no AUTHOR/ prefix): $dist\n";
609     my $filename = $1;
610
611     my $download_file = catfile( $src_dir, $filename );
612
613     # download distribution
614
615     if ( -f $download_file and !-s $download_file ) {
616
617         # wget can leave a zero-length file on failed download
618         unlink $download_file;
619     }
620
621     unless ( -f $download_file ) {
622
623         # not cached
624         $dist =~ /^([A-Z])([A-Z])/
625           or die
626 "ERROR: $module: invalid DISTRIBUTION name (not /^[A-Z]{2}/): $dist\n";
627
628         my $url =
629           cpan_url( $mirror_url, "modules/by-authors/id/$1/$1$2/$dist" );
630         my_getstore( $url, $download_file )
631           or die "ERROR: Could not fetch '$url'\n";
632     }
633
634     # get the expected name of the extracted distribution dir
635
636     my $path = catfile( $untar_dir, $filename );
637
638     $path =~ s/\.tar\.gz$//
639       or $path =~ s/\.zip$//
640       or die
641       "ERROR: downloaded file does not have a recognised suffix: $path\n";
642
643     # extract it unless we already have it cached or tarball is newer
644     if ( !-d $path || ( -M $download_file < -M $path ) ) {
645         my $ae = Archive::Extract->new( archive => $download_file );
646         $ae->extract( to => $untar_dir )
647           or die
648           "ERROR: failed to extract distribution '$download_file to temp. dir: "
649           . $ae->error() . "\n";
650
651         $path = $ae->extract_path;
652     }
653
654     die "ERROR: Extracted tarball does not appear as $path\n" unless -d $path;
655
656     return $path;
657 }
658
659 # produce the diff of a single file
660 sub file_diff {
661     my $outfh     = shift;
662     my $cpan_file = shift;
663     my $perl_file = shift;
664     my $reverse   = shift;
665     my $diff_opts = shift;
666
667     my @cmd = ( DIFF_CMD, split ' ', $diff_opts );
668     if ($reverse) {
669         push @cmd, $perl_file, $cpan_file;
670     }
671     else {
672         push @cmd, $cpan_file, $perl_file;
673     }
674     return `@cmd`;
675
676 }
677
678 sub customized {
679     my ( $module_data, $file ) = @_;
680     return grep { $file eq $_ } @{ $module_data->{CUSTOMIZED} };
681 }
682
683 run();
684