3 # This is a rough draft of a tool to aid in generating a perldelta file
4 # from a series of git commits.
9 package Git::DeltaTool;
20 BEGIN { struct( git => '$', last_tag => '$', opt => '%', original_stdout => '$' ) }
24 #--------------------------------------------------------------------------#
26 #--------------------------------------------------------------------------#
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
44 pod2usage() if $opt{help};
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 };
52 ($git_id) = $git->describe;
55 my $gdt = $class->new( git => $git, last_tag => $git_id, opt => \%opt );
57 if ( $opt{mode} eq 'assign' ) {
61 elsif ( $opt{mode} eq 'review' ) {
62 $opt{type} //= 'pending';
65 elsif ( $opt{mode} eq 'render' ) {
66 $opt{type} //= 'pending';
69 elsif ( $opt{mode} eq 'summary' ) {
70 $opt{type} //= 'pending';
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};
81 die "Unrecognized mode '$opt{mode}'\n";
86 #--------------------------------------------------------------------------#
87 # program modes (and iterator)
88 #--------------------------------------------------------------------------#
92 my @choices = ( $self->section_choices, $self->action_choices );
93 $self->_iterate_commits(
98 $self->show_header($log);
99 $self->show_body($log, 1);
101 return $self->dispatch( $self->prompt( @choices ), $log);
109 my @choices = ( $self->review_choices, $self->action_choices );
110 $self->_iterate_commits(
115 $self->show_header($log);
116 $self->show_notes($log, 1);
118 return $self->dispatch( $self->prompt( @choices ), $log);
127 $self->_iterate_commits(
130 my $section = $self->note_section($log) or return;
131 push @{ $sections{$section} }, $self->note_delta($log);
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};
142 say join ( "\n", @{ $sections{$s} }, "" );
149 $self->_iterate_commits(
152 $self->show_header($log);
162 my $status = $self->opt('status')
163 or die "The 'status' option must be supplied for update mode\n";
165 $self->_iterate_commits(
168 my $note = $log->notes;
169 $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1$status$2}ms;
170 $self->add_note( $log->id, $note );
177 sub _iterate_commits {
178 my ($self, $fcn) = @_;
179 my $type = $self->opt('type');
180 say STDERR "Scanning for $type commits since " . $self->last_tag . "...";
181 for my $log ( $self->find_commits($type) ) {
182 redo unless $fcn->($log);
187 #--------------------------------------------------------------------------#
189 #--------------------------------------------------------------------------#
192 my ($self, $id, $note) = @_;
193 my @lines = split "\n", _strip_comments($note);
194 pop @lines while @lines && $lines[-1] =~ m{^\s*$};
195 my $tempfh = File::Temp->new;
197 $tempfh->printflush( join( "\n", @lines), "\n" );
198 $self->git->notes('edit', '-F', "$tempfh", $id);
201 $tempfh->printflush( "\n" );
202 # git notes won't take an empty file as input
203 system("git notes edit -F $tempfh $id");
210 my ($self, $choice, $log) = @_;
211 return unless $choice;
212 my $method = "do_$choice->{handler}";
213 return 1 unless $self->can($method); # missing methods "succeed"
214 return $self->$method($choice, $log);
218 my ($self, $text, $args) = @_;
220 my $tempfh = File::Temp->new;
221 $tempfh->printflush( $text );
222 if ( my @editor = split /\s+/, ($ENV{VISUAL} || $ENV{EDITOR}) ) {
223 push @editor, "-f" if $editor[0] =~ /^gvim/;
224 system(@editor, "$tempfh");
227 warn("No VISUAL or EDITOR defined");
230 return do { local $/; <$tempfh> };
234 my ($self, $type) = @_;
236 my @commits = $self->git->log($self->last_tag . "..HEAD");
237 $_ = Git::Wrapper::XLog->from_log($_) for @commits;
239 if ( $type eq 'new' ) {
240 @list = grep { ! $_->notes } @commits;
243 @list = grep { $self->note_status( $_ ) eq $type } @commits;
249 my ($self, $log) = @_;
250 my @diff = $self->git->show({ stat => 1, p => 1 }, $log->id);
251 return join("\n", @diff);
255 my ($self, $log) = @_;
256 my @delta = split "\n", ($log->notes || '');
257 return '' unless @delta;
259 return join( "\n", @delta, "" );
263 my ($self, $log) = @_;
264 my $note = $log->notes or return '';
265 my ($section) = $note =~ m{^perldelta:\s*([^\[]*)\s+}ms;
266 return $section || '';
270 my ($self, $log) = @_;
271 my $note = $log->notes or return '';
272 my ($status) = $note =~ m{^perldelta:\s*[^\[]*\[(\w+)\]}ms;
273 return $status || '';
277 my ($self, $log, $text) = @_;
278 my $diff = _prepend_comment( $self->get_diff($log) );
280 # Edit commit note below. Do not change the first line. Comments are stripped
288 my ($self, @choices) = @_;
289 my ($valid, @menu, %keymap) = '';
290 for my $c ( map { @$_ } @choices ) {
291 my ($item) = grep { /\(/ } split q{ }, $c->{name};
292 my ($button) = $item =~ m{\((.)\)};
293 die "No key shortcut found for '$item'" unless $button;
294 die "Duplicate key shortcut found for '$item'" if $keymap{lc $button};
296 $valid .= lc $button;
297 $keymap{lc $button} = $c;
299 my $keypress = $self->prompt_key( $self->wrap_list(@menu), $valid );
300 return $keymap{lc $keypress};
304 my ($self, $prompt, $valid_keys) = @_;
309 $key = lc ReadKey(0);
311 if ( $key !~ qr/\A[$valid_keys]\z/i ) {
320 my ($self, $log, $lf) = @_;
321 return unless my $body = $log->body;
322 say $lf ? "\n$body" : $body;
327 my ($self, $log) = @_;
328 my $header = $log->short_id;
329 $header .= " " . $log->subject if length $log->subject;
330 say colored( $header, "yellow");
335 my ($self, $log, $lf) = @_;
336 return unless my $notes = $log->notes;
337 say $lf ? "\n$notes" : $notes;
342 my ($self, @list) = @_;
343 my $line = shift @list;
345 for my $item ( @list ) {
346 if ( length( $line . $item ) > 70 ) {
348 $line = $item ne $list[-1] ? $item : "or $item";
351 $line .= $item ne $list[-1] ? ", $item" : " or $item";
354 return join("\n", @wrap, $line);
358 my ($self, $msg) = @_;
359 my $key = $self->prompt_key($msg . " (y/n?)", 'yn');
363 #--------------------------------------------------------------------------#
365 #--------------------------------------------------------------------------#
368 my ($self, $choice, $log) = @_;
369 my $note = "perldelta: Unknown [blocking]\n";
370 $self->add_note( $log->id, $note );
375 my ($self, $choice, $log) = @_;
377 say $self->get_diff($log);
383 my ($self, $choice, $log) = @_;
384 my $id = $log->short_id;
385 $self->y_n("Recommend a cherry pick of '$id' to maint?") or return;
386 my $cherrymaint = dirname($0) . "/cherrymaint";
387 system("$^X $cherrymaint --vote $id");
388 return; # false will re-prompt the same commit
392 my ($self, $choice, $log) = @_;
393 my $note = $log->notes;
394 $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1done$2}ms;
395 $self->add_note( $log->id, $note );
400 my ($self, $choice, $log) = @_;
401 my $old_note = $log->notes;
402 my $new_note = $self->edit_text( $self->note_template( $log, $old_note) );
403 $self->add_note( $log->id, $new_note );
408 my ($self, $choice, $log) = @_;
409 my $section = _strip_parens($choice->{name});
410 my $subject = $log->subject;
411 my $body = $log->body;
413 my $template = $self->note_template( $log,
414 "perldelta: $section [pending]\n\n=head2 $subject\n\n$body\n"
417 my $note = $self->edit_text( $template );
418 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
419 $self->add_note( $log->id, $note );
426 my ($self, $choice, $log) = @_;
427 my $section = _strip_parens($choice->{name});
428 my $subject = $log->subject;
429 my $body = $log->body;
431 my $template = $self->note_template( $log,
432 "perldelta: $section [pending]\n\n=head3 L<LINK>\n\n=over\n\n=item *\n\n$subject\n\n$body\n\n=back\n"
435 my $note = $self->edit_text($template);
436 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
437 $self->add_note( $log->id, $note );
444 my ($self, $choice, $log) = @_;
445 my $section = _strip_parens($choice->{name});
446 my $subject = $log->subject;
447 my $body = $log->body;
449 my $template = $self->note_template( $log,
450 "perldelta: $section [pending]\n\n=item *\n\n$subject\n\n$body\n"
453 my $note = $self->edit_text($template);
454 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
455 $self->add_note( $log->id, $note );
462 my ($self, $choice, $log) = @_;
463 my $note = "perldelta: None [ignored]\n";
464 $self->add_note( $log->id, $note );
469 my ($self, $choice, $log) = @_;
470 my $section = _strip_parens($choice->{name});
471 my $subject = $log->subject;
472 my $body = $log->body;
474 my $template = $self->note_template( $log,
475 "perldelta: $section [pending]\n\n=item PLATFORM-NAME\n\n$subject\n\n$body\n"
478 my $note = $self->edit_text($template);
479 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
480 $self->add_note( $log->id, $note );
486 sub do_quit { exit 0 }
488 sub do_repeat { return 0 }
490 sub do_skip { return 1 }
493 my ($self, $choice, $log) = @_;
494 my $section = _strip_parens($choice->{name});
495 my $subject = $log->subject;
496 my $body = $log->body;
498 my $template = $self->note_template( $log, << "HERE" );
499 perldelta: $section [pending]
506 my $note = $self->edit_text( $template );
507 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
508 $self->add_note( $log->id, $note );
515 my ($self, $choice, $log) = @_;
516 my @choices = ( $choice->{subsection}, $self->submenu_choices );
517 say "For " . _strip_parens($choice->{name}) . ":";
518 return $self->dispatch( $self->prompt( @choices ), $log);
521 #--------------------------------------------------------------------------#
523 #--------------------------------------------------------------------------#
527 state $action_choices = [
528 { name => 'E(x)amine', handler => 'examine' },
529 { name => '(+)Cherrymaint', handler => 'cherry' },
530 { name => '(?)NeedHelp', handler => 'blocking' },
531 { name => 'S(k)ip', handler => 'skip' },
532 { name => '(Q)uit', handler => 'quit' },
534 return $action_choices;
537 sub submenu_choices {
539 state $submenu_choices = [
540 { name => '(B)ack', handler => 'repeat' },
542 return $submenu_choices;
548 state $action_choices = [
549 { name => '(E)dit', handler => 'edit' },
550 { name => '(I)gnore', handler => 'none' },
551 { name => '(D)one', handler => 'done' },
553 return $action_choices;
556 sub section_choices {
557 my ($self, $key) = @_;
558 state $section_choices = [
559 # Headline stuff that should go first
561 name => 'Core (E)nhancements',
565 name => 'Securit(y)',
569 name => '(I)ncompatible Changes',
573 name => 'Dep(r)ecations',
577 name => '(P)erformance Enhancements',
581 # Details on things installed with Perl (for Perl developers)
583 name => '(M)odules and Pragmata',
584 handler => 'subsection',
587 name => '(N)ew Modules and Pragmata',
591 name => '(U)pdated Modules and Pragmata',
595 name => '(R)emoved Modules and Pragmata',
601 name => '(D)ocumentation',
602 handler => 'subsection',
605 name => '(N)ew Documentation',
606 handler => 'linked_item',
609 name => '(C)hanges to Existing Documentation',
610 handler => 'linked_item',
615 name => 'Dia(g)nostics',
616 handler => 'subsection',
619 name => '(N)ew Diagnostics',
623 name => '(C)hanges to Existing Diagnostics',
629 name => '(U)tilities',
630 handler => 'linked_item',
633 # Details on building/testing Perl (for porters and packagers)
635 name => '(C)onfiguration and Compilation',
639 name => '(T)esting', # new tests or significant notes about it
643 name => 'Pl(a)tform Support',
644 handler => 'subsection',
647 name => '(N)ew Platforms',
648 handler => 'platform',
651 name => '(D)iscontinued Platforms',
652 handler => 'platform',
655 name => '(P)latform-Specific Notes',
656 handler => 'platform',
661 # Details on perl internals (for porters and XS developers)
663 name => 'Inter(n)al Changes',
667 # Bugs fixed and related stuff
669 name => 'Selected Bug (F)ixes',
673 name => 'Known Prob(l)ems',
677 # dummy options for special handling
680 handler => 'special',
687 return $section_choices;
694 for my $c ( @{ $self->section_choices } ) {
695 if ( $c->{subsection} ) {
696 push @order, map { $_->{name} } @{$c->{subsection}};
699 push @order, $c->{name};
706 #--------------------------------------------------------------------------#
708 #--------------------------------------------------------------------------#
710 sub get_pager { $ENV{'PAGER'} || `which less` || `which more` }
712 sub in_pager { shift->original_stdout ? 1 : 0 }
717 if (!$self->in_pager) {
718 local $ENV{'LESS'} ||= '-FXe';
720 $ENV{'MORE'} ||= '-FXe' unless $^O =~ /^MSWin/;
722 my $pager = $self->get_pager;
723 return unless $pager;
724 open (my $cmd, "|-", $pager) || return;
726 $self->original_stdout(*STDOUT);
728 # $pager will be closed once we restore STDOUT to $original_stdout
735 return unless ($self->in_pager);
736 *STDOUT = $self->original_stdout;
739 $self->original_stdout(undef);
742 #--------------------------------------------------------------------------#
744 #--------------------------------------------------------------------------#
752 sub _prepend_comment {
754 return join ("\n", map { s/^/# /g; $_ } split "\n", $text);
757 sub _strip_comments {
759 return join ("\n", grep { ! /^#/ } split "\n", $text);
762 #--------------------------------------------------------------------------#
763 # Extend Git::Wrapper::Log
764 #--------------------------------------------------------------------------#
766 package Git::Wrapper::XLog;
767 BEGIN { our @ISA = qw/Git::Wrapper::Log/; }
769 sub subject { shift->attr->{subject} }
770 sub body { shift->attr->{body} }
771 sub short_id { shift->attr->{short_id} }
774 my ($class, $log) = @_;
776 my $msg = $log->message;
777 my ($subject, $body) = $msg =~ m{^([^\n]+)\n*(.*)}ms;
780 $body =~ s/[\r\n]*\z//ms;
782 my ($short) = Git::Wrapper->new(".")->rev_parse({short => 1}, $log->id);
784 $log->attr->{subject} = $subject;
785 $log->attr->{body} = $body;
786 $log->attr->{short_id} = $short;
787 return bless $log, $class;
792 my @notes = eval { Git::Wrapper->new(".")->notes('show', $self->id) };
793 pop @notes while @notes && $notes[-1] =~ m{^\s*$};
794 return unless @notes;
795 return join ("\n", @notes);
802 git-deltatool - Annotate commits for perldelta
806 # annotate commits back to last 'git describe' tag
812 $ git-deltatool --mode review
814 # review commits needing help
816 $ git-deltatool --mode review --type blocking
818 # summarize commits needing help
820 $ git-deltatool --mode summary --type blocking
822 # assemble annotations by section to STDOUT
824 $ git-deltatool --mode render
826 # Get a list of commits needing further review, e.g. for peer review
828 $ git-deltatool --mode summary --type blocking
830 # mark 'pending' annotations as 'done' (i.e. added to perldelta)
832 $ git-deltatool --mode update --type pending --status done
838 =item B<--mode>|B<-m> MODE
840 Indicates the run mode for the program. The default is 'assign' which
841 assigns categories and marks the notes as 'pending' (or 'ignored'). Other
842 modes are 'review', 'render', 'summary' and 'update'.
844 =item B<--type>|B<-t> TYPE
846 Indicates what types of commits to process. The default for 'assign' mode is
847 'new', which processes commits without any perldelta notes. The default for
848 'review', 'summary' and 'render' modes is 'pending'. The options must be set
849 explicitly for 'update' mode.
851 The type 'blocking' is reserved for commits needing further review.
853 =item B<--status>|B<-s> STATUS
855 For 'update' mode only, sets a new status. While there is no restriction,
856 it should be one of 'new', 'pending', 'blocking', 'ignored' or 'done'.
858 =item B<--since> REVISION
860 Defines the boundary for searching git commits. Defaults to the last
861 major tag (as would be given by 'git describe').
871 It would be nice to make some of the structured sections smarter -- e.g.
872 look at changed files in pod/* for Documentation section entries. Likewise
873 it would be nice to collate them during the render phase -- e.g. cluster
874 all platform-specific things properly.
878 David Golden <dagolden@cpan.org>
880 =head1 COPYRIGHT AND LICENSE
882 This software is copyright (c) 2010 by David Golden.
884 This is free software; you can redistribute it and/or modify it under the same
885 terms as the Perl 5 programming language system itself.