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