xref: /openbsd-src/gnu/usr.bin/perl/Porting/git-deltatool (revision 91f110e064cd7c194e59e019b83bb7496c1c84d4)
1#!/usr/bin/perl
2#
3# This is a rough draft of a tool to aid in generating a perldelta file
4# from a series of git commits.
5
6use 5.010;
7use strict;
8use warnings;
9package Git::DeltaTool;
10
11use Class::Struct;
12use File::Basename;
13use File::Temp;
14use Getopt::Long;
15use Git::Wrapper;
16use Term::ReadKey;
17use Term::ANSIColor;
18use Pod::Usage;
19
20BEGIN { struct( git => '$', last_tag => '$', opt => '%', original_stdout => '$' ) }
21
22__PACKAGE__->run;
23
24#--------------------------------------------------------------------------#
25# main program
26#--------------------------------------------------------------------------#
27
28sub run {
29  my $class = shift;
30
31  my %opt = (
32    mode => 'assign',
33  );
34
35  GetOptions( \%opt,
36    # inputs
37    'mode|m:s', # 'assign', 'review', 'render', 'update'
38    'type|t:s', # select by status
39    'status|s:s', # status to set for 'update'
40    'since:s', # origin commit
41    'help|h',  # help
42  );
43
44  pod2usage() if $opt{help};
45
46  my $git = Git::Wrapper->new(".");
47  my $git_id = $opt{since};
48  if ( defined $git_id ) {
49    die "Invalid git identifier '$git_id'\n"
50      unless eval { $git->show($git_id); 1 };
51  } else {
52    ($git_id) = $git->describe;
53    $git_id =~ s/-.*$//;
54  }
55  my $gdt = $class->new( git => $git, last_tag => $git_id, opt => \%opt );
56
57  if ( $opt{mode} eq 'assign' ) {
58    $opt{type} //= 'new';
59    $gdt->assign;
60  }
61  elsif ( $opt{mode} eq 'review' ) {
62    $opt{type} //= 'pending';
63    $gdt->review;
64  }
65  elsif ( $opt{mode} eq 'render' ) {
66    $opt{type} //= 'pending';
67    $gdt->render;
68  }
69  elsif ( $opt{mode} eq 'summary' ) {
70    $opt{type} //= 'pending';
71    $gdt->summary;
72  }
73  elsif ( $opt{mode} eq 'update' ) {
74    die "Explicit --type argument required for update mode\n"
75      unless defined $opt{type};
76    die "Explicit --status argument required for update mode\n"
77      unless defined $opt{status};
78    $gdt->update;
79  }
80  else {
81    die "Unrecognized mode '$opt{mode}'\n";
82  }
83  exit 0;
84}
85
86#--------------------------------------------------------------------------#
87# program modes (and iterator)
88#--------------------------------------------------------------------------#
89
90sub assign {
91  my ($self) = @_;
92  my @choices = ( $self->section_choices, $self->action_choices );
93  $self->_iterate_commits(
94    sub {
95      my ($log, $i, $count) = @_;
96      say "\n### Commit @{[$i+1]} of $count ###";
97      say "-" x 75;
98      $self->show_header($log);
99      $self->show_body($log, 1);
100      say "-" x 75;
101      return $self->dispatch( $self->prompt( @choices ), $log);
102    }
103  );
104  return;
105}
106
107sub review {
108  my ($self) = @_;
109  my @choices = ( $self->review_choices, $self->action_choices );
110  $self->_iterate_commits(
111    sub {
112      my ($log, $i, $count) = @_;
113      say "\n### Commit @{[$i+1]} of $count ###";
114      say "-" x 75;
115      $self->show_header($log);
116      $self->show_notes($log, 1);
117      say "-" x 75;
118      return $self->dispatch( $self->prompt( @choices ), $log);
119    }
120  );
121  return;
122}
123
124sub render {
125  my ($self) = @_;
126  my %sections;
127  $self->_iterate_commits(
128    sub {
129      my $log = shift;
130      my $section = $self->note_section($log) or return;
131      push @{ $sections{$section} }, $self->note_delta($log);
132      return 1;
133    }
134  );
135  my @order = $self->section_order;
136  my %known = map { $_ => 1 } @order;
137  my @rest = grep { ! $known{$_} } keys %sections;
138  for my $s ( @order, @rest ) {
139    next unless ref $sections{$s};
140    say "-"x75;
141    say uc($s) . "\n";
142    say join ( "\n", @{ $sections{$s} }, "" );
143  }
144  return;
145}
146
147sub summary {
148  my ($self) = @_;
149  $self->_iterate_commits(
150    sub {
151      my $log = shift;
152      $self->show_header($log);
153      return 1;
154    }
155  );
156  return;
157}
158
159sub update {
160  my ($self) = @_;
161
162  my $status = $self->opt('status')
163    or die "The 'status' option must be supplied for update mode\n";
164
165  $self->_iterate_commits(
166    sub {
167      my $log = shift;
168      my $note = $log->notes;
169      $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1$status$2}ms;
170      $self->add_note( $log->id, $note );
171      return 1;
172    }
173  );
174  return;
175}
176
177sub _iterate_commits {
178  my ($self, $fcn) = @_;
179  my $type = $self->opt('type');
180  say STDERR "Scanning for $type commits since " . $self->last_tag . "...";
181  my $list = [ $self->find_commits($type) ];
182  my $count = @$list;
183  while ( my ($i,$log) = each @$list ) {
184    redo unless $fcn->($log, $i, $count);
185  }
186  return 1;
187}
188
189#--------------------------------------------------------------------------#
190# methods
191#--------------------------------------------------------------------------#
192
193sub add_note {
194  my ($self, $id, $note) = @_;
195  my @lines = split "\n", _strip_comments($note);
196  pop @lines while @lines && $lines[-1] =~ m{^\s*$};
197  my $tempfh = File::Temp->new;
198  if (@lines) {
199    $tempfh->printflush( join( "\n", @lines), "\n" );
200    $self->git->notes('edit', '-F', "$tempfh", $id);
201  }
202  else {
203    $tempfh->printflush( "\n" );
204    # git notes won't take an empty file as input
205    system("git notes edit -F $tempfh $id");
206  }
207
208  return;
209}
210
211sub dispatch {
212  my ($self, $choice, $log) = @_;
213  return unless $choice;
214  my $method = "do_$choice->{handler}";
215  return 1 unless $self->can($method); # missing methods "succeed"
216  return $self->$method($choice, $log);
217}
218
219sub edit_text {
220  my ($self, $text, $args) = @_;
221  $args //= {};
222  my $tempfh = File::Temp->new;
223  $tempfh->printflush( $text );
224  if ( my @editor = split /\s+/, ($ENV{VISUAL} || $ENV{EDITOR}) ) {
225    push @editor, "-f" if $editor[0] =~ /^gvim/;
226    system(@editor, "$tempfh");
227  }
228  else {
229    warn("No VISUAL or EDITOR defined");
230  }
231  $tempfh->seek(0,0);
232  return do { local $/; <$tempfh> };
233}
234
235sub find_commits {
236  my ($self, $type) = @_;
237  $type //= 'new';
238  my @commits = $self->git->log($self->last_tag . "..HEAD");
239  $_ = Git::Wrapper::XLog->from_log($_) for @commits;
240  my @list;
241  if ( $type eq 'new' ) {
242    @list = grep { ! $_->notes } @commits;
243  }
244  else {
245    @list = grep { $self->note_status( $_ ) eq $type } @commits;
246  }
247  return @list;
248}
249
250sub get_diff {
251  my ($self, $log) = @_;
252  my @diff = $self->git->show({ stat => 1, p => 1 }, $log->id);
253  return join("\n", @diff);
254}
255
256sub note_delta {
257  my ($self, $log) = @_;
258  my @delta = split "\n", ($log->notes || '');
259  return '' unless @delta;
260  splice @delta, 0, 2;
261  return join( "\n", @delta, "" );
262}
263
264sub note_section {
265  my ($self, $log) = @_;
266  my $note = $log->notes or return '';
267  my ($section) = $note =~ m{^perldelta:\s*([^\[]*)\s+}ms;
268  return $section || '';
269}
270
271sub note_status {
272  my ($self, $log) = @_;
273  my $note = $log->notes or return '';
274  my ($status) = $note =~ m{^perldelta:\s*[^\[]*\[(\w+)\]}ms;
275  return $status || '';
276}
277
278sub note_template {
279  my ($self, $log, $text) = @_;
280  my $diff = _prepend_comment( $self->get_diff($log) );
281  return << "HERE";
282# Edit commit note below. Do not change the first line. Comments are stripped
283$text
284
285$diff
286HERE
287}
288
289sub prompt {
290  my ($self, @choices) = @_;
291  my ($valid, @menu, %keymap) = '';
292  for my $c ( map { @$_ } @choices ) {
293    my ($item) = grep { /\(/ } split q{ }, $c->{name};
294    my ($button) = $item =~ m{\((.)\)};
295    die "No key shortcut found for '$item'" unless $button;
296    die "Duplicate key shortcut found for '$item'" if $keymap{lc $button};
297    push @menu, $item;
298    $valid .= lc $button;
299    $keymap{lc $button} = $c;
300  }
301  my $keypress = $self->prompt_key( $self->wrap_list(@menu), $valid );
302  return $keymap{lc $keypress};
303}
304
305sub prompt_key {
306  my ($self, $prompt, $valid_keys) = @_;
307  my $key;
308  KEY: {
309    say $prompt;
310    ReadMode 3;
311    $key = lc ReadKey(0);
312    ReadMode 0;
313    if ( $key !~ qr/\A[$valid_keys]\z/i ) {
314      say "";
315      redo KEY;
316    }
317  }
318  return $key;
319}
320
321sub show_body {
322  my ($self, $log, $lf) = @_;
323  return unless my $body = $log->body;
324  say $lf ? "\n$body" : $body;
325  return;
326}
327
328sub show_header {
329  my ($self, $log) = @_;
330  my $header = $log->short_id;
331  $header .= " " . $log->subject if length $log->subject;
332  $header .= sprintf(' (%s)', $log->author) if $log->author;
333  say colored( $header, "yellow");
334  return;
335}
336
337sub show_notes {
338  my ($self, $log, $lf) = @_;
339  return unless my $notes = $log->notes;
340  say $lf ? "\n$notes" : $notes;
341  return;
342}
343
344sub wrap_list {
345  my ($self, @list) = @_;
346  my $line = shift @list;
347  my @wrap;
348  for my $item ( @list ) {
349    if ( length( $line . $item ) > 70 ) {
350      push @wrap, $line;
351      $line = $item ne $list[-1] ? $item : "or $item";
352    }
353    else {
354      $line .= $item ne $list[-1] ? ", $item" : " or $item";
355    }
356  }
357  return join("\n", @wrap, $line);
358}
359
360sub y_n {
361  my ($self, $msg) = @_;
362  my $key = $self->prompt_key($msg . " (y/n?)", 'yn');
363  return $key eq 'y';
364}
365
366#--------------------------------------------------------------------------#
367# handlers
368#--------------------------------------------------------------------------#
369
370sub do_blocking {
371  my ($self, $choice, $log) = @_;
372  my $note = "perldelta: Unknown [blocking]\n";
373  $self->add_note( $log->id, $note );
374  return 1;
375}
376
377sub do_examine {
378  my ($self, $choice, $log) = @_;
379  $self->start_pager;
380  say $self->get_diff($log);
381  $self->end_pager;
382  return;
383}
384
385sub do_cherry {
386  my ($self, $choice, $log) = @_;
387  my $id = $log->short_id;
388  $self->y_n("Recommend a cherry pick of '$id' to maint?") or return;
389  my $cherrymaint = dirname($0) . "/cherrymaint";
390  system("$^X $cherrymaint --vote $id");
391  return; # false will re-prompt the same commit
392}
393
394sub do_done {
395  my ($self, $choice, $log) = @_;
396  my $note = $log->notes;
397  $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1done$2}ms;
398  $self->add_note( $log->id, $note );
399  return 1;
400}
401
402sub do_edit {
403  my ($self, $choice, $log) = @_;
404  my $old_note = $log->notes;
405  my $new_note = $self->edit_text( $self->note_template( $log, $old_note) );
406  $self->add_note( $log->id, $new_note );
407  return 1;
408}
409
410sub do_head2 {
411  my ($self, $choice, $log) = @_;
412  my $section = _strip_parens($choice->{name});
413  my $subject = $log->subject;
414  my $body = $log->body;
415
416  my $template = $self->note_template( $log,
417    "perldelta: $section [pending]\n\n=head2 $subject\n\n$body\n"
418  );
419
420  my $note = $self->edit_text( $template );
421  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
422    $self->add_note( $log->id, $note );
423    return 1;
424  }
425  return;
426}
427
428sub do_linked_item {
429  my ($self, $choice, $log) = @_;
430  my $section = _strip_parens($choice->{name});
431  my $subject = $log->subject;
432  my $body = $log->body;
433
434  my $template = $self->note_template( $log,
435    "perldelta: $section [pending]\n\n=head3 L<LINK>\n\n=over\n\n=item *\n\n$subject\n\n$body\n\n=back\n"
436  );
437
438  my $note = $self->edit_text($template);
439  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
440    $self->add_note( $log->id, $note );
441    return 1;
442  }
443  return;
444}
445
446sub do_item {
447  my ($self, $choice, $log) = @_;
448  my $section = _strip_parens($choice->{name});
449  my $subject = $log->subject;
450  my $body = $log->body;
451
452  my $template = $self->note_template( $log,
453    "perldelta: $section [pending]\n\n=item *\n\n$subject\n\n$body\n"
454  );
455
456  my $note = $self->edit_text($template);
457  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
458    $self->add_note( $log->id, $note );
459    return 1;
460  }
461  return;
462}
463
464sub do_none {
465  my ($self, $choice, $log) = @_;
466  my $note = "perldelta: None [ignored]\n";
467  $self->add_note( $log->id, $note );
468  return 1;
469}
470
471sub do_platform {
472  my ($self, $choice, $log) = @_;
473  my $section = _strip_parens($choice->{name});
474  my $subject = $log->subject;
475  my $body = $log->body;
476
477  my $template = $self->note_template( $log,
478    "perldelta: $section [pending]\n\n=item PLATFORM-NAME\n\n$subject\n\n$body\n"
479  );
480
481  my $note = $self->edit_text($template);
482  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
483    $self->add_note( $log->id, $note );
484    return 1;
485  }
486  return;
487}
488
489sub do_quit { exit 0 }
490
491sub do_repeat { return 0 }
492
493sub do_skip { return 1 }
494
495sub do_special {
496  my ($self, $choice, $log) = @_;
497  my $section = _strip_parens($choice->{name});
498  my $subject = $log->subject;
499  my $body = $log->body;
500
501  my $template = $self->note_template( $log, << "HERE" );
502perldelta: $section [pending]
503
504$subject
505
506$body
507HERE
508
509  my $note = $self->edit_text( $template );
510  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
511    $self->add_note( $log->id, $note );
512    return 1;
513  }
514  return;
515}
516
517sub do_subsection {
518  my ($self, $choice, $log) = @_;
519  my @choices = ( $choice->{subsection}, $self->submenu_choices );
520  say "For " . _strip_parens($choice->{name}) . ":";
521  return $self->dispatch( $self->prompt( @choices ), $log);
522}
523
524#--------------------------------------------------------------------------#
525# define prompts
526#--------------------------------------------------------------------------#
527
528sub action_choices {
529  my ($self) = @_;
530  state $action_choices = [
531      { name => 'E(x)amine', handler => 'examine' },
532      { name => '(+)Cherrymaint', handler => 'cherry' },
533      { name => '(?)NeedHelp', handler => 'blocking' },
534      { name => 'S(k)ip', handler => 'skip' },
535      { name => '(Q)uit', handler => 'quit' },
536  ];
537  return $action_choices;
538}
539
540sub submenu_choices {
541  my ($self) = @_;
542  state $submenu_choices = [
543      { name => '(B)ack', handler => 'repeat' },
544  ];
545  return $submenu_choices;
546}
547
548
549sub review_choices {
550  my ($self) = @_;
551  state $action_choices = [
552      { name => '(E)dit', handler => 'edit' },
553      { name => '(I)gnore', handler => 'none' },
554      { name => '(D)one', handler => 'done' },
555  ];
556  return $action_choices;
557}
558
559sub section_choices {
560  my ($self, $key) = @_;
561  state $section_choices = [
562    # Headline stuff that should go first
563    {
564      name => 'Core (E)nhancements',
565      handler => 'head2',
566    },
567    {
568      name => 'Securit(y)',
569      handler => 'head2',
570    },
571    {
572      name => '(I)ncompatible Changes',
573      handler => 'head2',
574    },
575    {
576      name => 'Dep(r)ecations',
577      handler => 'head2',
578    },
579    {
580      name => '(P)erformance Enhancements',
581      handler => 'item',
582    },
583
584    # Details on things installed with Perl (for Perl developers)
585    {
586      name => '(M)odules and Pragmata',
587      handler => 'subsection',
588      subsection => [
589        {
590          name => '(N)ew Modules and Pragmata',
591          handler => 'item',
592        },
593        {
594          name => '(U)pdated Modules and Pragmata',
595          handler => 'item',
596        },
597        {
598          name => '(R)emoved Modules and Pragmata',
599          handler => 'item',
600        },
601      ],
602    },
603    {
604      name => '(D)ocumentation',
605      handler => 'subsection',
606      subsection => [
607        {
608          name => '(N)ew Documentation',
609          handler => 'linked_item',
610        },
611        {
612          name => '(C)hanges to Existing Documentation',
613          handler => 'linked_item',
614        },
615      ],
616    },
617    {
618      name => 'Dia(g)nostics',
619      handler => 'subsection',
620      subsection => [
621        {
622          name => '(N)ew Diagnostics',
623          handler => 'item',
624        },
625        {
626          name => '(C)hanges to Existing Diagnostics',
627          handler => 'item',
628        },
629      ],
630    },
631    {
632      name => '(U)tilities',
633      handler => 'linked_item',
634    },
635
636    # Details on building/testing Perl (for porters and packagers)
637    {
638      name => '(C)onfiguration and Compilation',
639      handler => 'item',
640    },
641    {
642      name => '(T)esting', # new tests or significant notes about it
643      handler => 'item',
644    },
645    {
646      name => 'Pl(a)tform Support',
647      handler => 'subsection',
648      subsection => [
649        {
650          name => '(N)ew Platforms',
651          handler => 'platform',
652        },
653        {
654          name => '(D)iscontinued Platforms',
655          handler => 'platform',
656        },
657        {
658          name => '(P)latform-Specific Notes',
659          handler => 'platform',
660        },
661      ],
662    },
663
664    # Details on perl internals (for porters and XS developers)
665    {
666      name => 'Inter(n)al Changes',
667      handler => 'item',
668    },
669
670    # Bugs fixed and related stuff
671    {
672      name => 'Selected Bug (F)ixes',
673      handler => 'item',
674    },
675    {
676      name => 'Known Prob(l)ems',
677      handler => 'item',
678    },
679
680    # dummy options for special handling
681    {
682      name => '(S)pecial',
683      handler => 'special',
684    },
685    {
686      name => '(*)None',
687      handler => 'none',
688    },
689  ];
690  return $section_choices;
691}
692
693sub section_order {
694  my ($self) = @_;
695  state @order;
696  if ( ! @order ) {
697    for my $c ( @{ $self->section_choices } ) {
698      if ( $c->{subsection} ) {
699        push @order, map { $_->{name} } @{$c->{subsection}};
700      }
701      else {
702        push @order, $c->{name};
703      }
704    }
705  }
706  return @order;
707}
708
709#--------------------------------------------------------------------------#
710# Pager handling
711#--------------------------------------------------------------------------#
712
713sub get_pager { $ENV{'PAGER'} || `which less` || `which more` }
714
715sub in_pager { shift->original_stdout ? 1 : 0 }
716
717sub start_pager {
718  my $self = shift;
719  my $content = shift;
720  if (!$self->in_pager) {
721    local $ENV{'LESS'} ||= '-FXe';
722    local $ENV{'MORE'};
723    $ENV{'MORE'} ||= '-FXe' unless $^O =~ /^MSWin/;
724
725    my $pager = $self->get_pager;
726    return unless $pager;
727    open (my $cmd, "|-", $pager) || return;
728    $|++;
729    $self->original_stdout(*STDOUT);
730
731    # $pager will be closed once we restore STDOUT to $original_stdout
732    *STDOUT = $cmd;
733  }
734}
735
736sub end_pager {
737  my $self = shift;
738  return unless ($self->in_pager);
739  *STDOUT = $self->original_stdout;
740
741  # closes the pager
742  $self->original_stdout(undef);
743}
744
745#--------------------------------------------------------------------------#
746# Utility functions
747#--------------------------------------------------------------------------#
748
749sub _strip_parens {
750  my ($name) = @_;
751  $name =~ s/[()]//g;
752  return $name;
753}
754
755sub _prepend_comment {
756  my ($text) = @_;
757  return join ("\n", map { s/^/# /g; $_ } split "\n", $text);
758}
759
760sub _strip_comments {
761  my ($text) = @_;
762  return join ("\n", grep { ! /^#/ } split "\n", $text);
763}
764
765#--------------------------------------------------------------------------#
766# Extend Git::Wrapper::Log
767#--------------------------------------------------------------------------#
768
769package Git::Wrapper::XLog;
770BEGIN { our @ISA = qw/Git::Wrapper::Log/; }
771
772sub subject { shift->attr->{subject} }
773sub body { shift->attr->{body} }
774sub short_id { shift->attr->{short_id} }
775sub author { shift->attr->{author} }
776
777sub from_log {
778  my ($class, $log) = @_;
779
780  my $msg = $log->message;
781  my ($subject, $body) = $msg =~ m{^([^\n]+)\n*(.*)}ms;
782  $subject //= '';
783  $body //= '';
784  $body =~ s/[\r\n]*\z//ms;
785
786  my ($short) = Git::Wrapper->new(".")->rev_parse({short => 1}, $log->id);
787
788  $log->attr->{subject} = $subject;
789  $log->attr->{body} = $body;
790  $log->attr->{short_id} = $short;
791  return bless $log, $class;
792}
793
794sub notes {
795  my ($self) = @_;
796  my @notes = eval { Git::Wrapper->new(".")->notes('show', $self->id) };
797  pop @notes while @notes && $notes[-1] =~ m{^\s*$};
798  return unless @notes;
799  return join ("\n", @notes);
800}
801
802__END__
803
804=head1 NAME
805
806git-deltatool - Annotate commits for perldelta
807
808=head1 SYNOPSIS
809
810 # annotate commits back to last 'git describe' tag
811
812 $ git-deltatool
813
814 # review annotations
815
816 $ git-deltatool --mode review
817
818 # review commits needing help
819
820 $ git-deltatool --mode review --type blocking
821
822 # summarize commits needing help
823
824 $ git-deltatool --mode summary --type blocking
825
826 # assemble annotations by section to STDOUT
827
828 $ git-deltatool --mode render
829
830 # Get a list of commits needing further review, e.g. for peer review
831
832 $ git-deltatool --mode summary --type blocking
833
834 # mark 'pending' annotations as 'done' (i.e. added to perldelta)
835
836 $ git-deltatool --mode update --type pending --status done
837
838=head1 OPTIONS
839
840=over
841
842=item B<--mode>|B<-m> MODE
843
844Indicates the run mode for the program.  The default is 'assign' which
845assigns categories and marks the notes as 'pending' (or 'ignored').  Other
846modes are 'review', 'render', 'summary' and 'update'.
847
848=item B<--type>|B<-t> TYPE
849
850Indicates what types of commits to process.  The default for 'assign' mode is
851'new', which processes commits without any perldelta notes.  The default for
852'review', 'summary' and 'render' modes is 'pending'.  The options must be set
853explicitly for 'update' mode.
854
855The type 'blocking' is reserved for commits needing further review.
856
857=item B<--status>|B<-s> STATUS
858
859For 'update' mode only, sets a new status.  While there is no restriction,
860it should be one of 'new', 'pending', 'blocking', 'ignored' or 'done'.
861
862=item B<--since> REVISION
863
864Defines the boundary for searching git commits.  Defaults to the last
865major tag (as would be given by 'git describe').
866
867=item B<--help>
868
869Shows the manual.
870
871=back
872
873=head1 TODO
874
875It would be nice to make some of the structured sections smarter -- e.g.
876look at changed files in pod/* for Documentation section entries.  Likewise
877it would be nice to collate them during the render phase -- e.g. cluster
878all platform-specific things properly.
879
880=head1 AUTHOR
881
882David Golden <dagolden@cpan.org>
883
884=head1 COPYRIGHT AND LICENSE
885
886This software is copyright (c) 2010 by David Golden.
887
888This is free software; you can redistribute it and/or modify it under the same
889terms as the Perl 5 programming language system itself.
890
891=cut
892
893