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 => '%' ) }
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 "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) = $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) = @_;
376 my $id = $log->short_id;
377 $self->y_n("Recommend a cherry pick of '$id' to maint?") or return;
378 my $cherrymaint = dirname($0) . "/cherrymaint";
379 system("$^X $cherrymaint --vote $id");
380 return; # false will re-prompt the same commit
384 my ($self, $choice, $log) = @_;
385 my $note = $log->notes;
386 $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1done$2}ms;
387 $self->add_note( $log->id, $note );
392 my ($self, $choice, $log) = @_;
393 my $old_note = $log->notes;
394 my $new_note = $self->edit_text( $self->note_template( $log, $old_note) );
395 $self->add_note( $log->id, $new_note );
400 my ($self, $choice, $log) = @_;
401 my $section = _strip_parens($choice->{name});
402 my $subject = $log->subject;
403 my $body = $log->body;
404 my $id = $log->short_id;
406 my $template = $self->note_template( $log,
407 "perldelta: $section [pending]\n\n=head2 $subject\n\n$body ($id)\n"
410 my $note = $self->edit_text( $template );
411 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
412 $self->add_note( $log->id, $note );
419 my ($self, $choice, $log) = @_;
420 my $section = _strip_parens($choice->{name});
421 my $subject = $log->subject;
422 my $body = $log->body;
423 my $id = $log->short_id;
425 my $template = $self->note_template( $log,
426 "perldelta: $section [pending]\n\n=head3 L<LINK>\n\n=over\n\n=item *\n\n$subject ($id)\n\n$body\n\n=back\n"
429 my $note = $self->edit_text($template);
430 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
431 $self->add_note( $log->id, $note );
438 my ($self, $choice, $log) = @_;
439 my $section = _strip_parens($choice->{name});
440 my $subject = $log->subject;
441 my $body = $log->body;
442 my $id = $log->short_id;
444 my $template = $self->note_template( $log,
445 "perldelta: $section [pending]\n\n=item *\n\n$subject ($id)\n\n$body\n"
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 );
457 my ($self, $choice, $log) = @_;
458 my $note = "perldelta: None [ignored]\n";
459 $self->add_note( $log->id, $note );
464 my ($self, $choice, $log) = @_;
465 my $section = _strip_parens($choice->{name});
466 my $subject = $log->subject;
467 my $body = $log->body;
468 my $id = $log->short_id;
470 my $template = $self->note_template( $log,
471 "perldelta: $section [pending]\n\n=item PLATFORM-NAME\n\n$subject ($id)\n\n$body\n"
474 my $note = $self->edit_text($template);
475 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
476 $self->add_note( $log->id, $note );
482 sub do_quit { exit 0 }
484 sub do_repeat { return 0 }
486 sub do_skip { return 1 }
489 my ($self, $choice, $log) = @_;
490 my $section = _strip_parens($choice->{name});
491 my $subject = $log->subject;
492 my $body = $log->body;
493 my $id = $log->short_id;
495 my $template = $self->note_template( $log, << "HERE" );
496 perldelta: $section [pending]
503 my $note = $self->edit_text( $template );
504 if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
505 $self->add_note( $log->id, $note );
512 my ($self, $choice, $log) = @_;
513 my @choices = ( $choice->{subsection}, $self->submenu_choices );
514 say "For " . _strip_parens($choice->{name}) . ":";
515 return $self->dispatch( $self->prompt( @choices ), $log);
518 #--------------------------------------------------------------------------#
520 #--------------------------------------------------------------------------#
524 state $action_choices = [
525 { name => '(+)Cherrymaint', handler => 'cherry' },
526 { name => '(?)NeedHelp', handler => 'blocking' },
527 { name => 'S(k)ip', handler => 'skip' },
528 { name => '(Q)uit', handler => 'quit' },
530 return $action_choices;
533 sub submenu_choices {
535 state $submenu_choices = [
536 { name => '(B)ack', handler => 'repeat' },
538 return $submenu_choices;
544 state $action_choices = [
545 { name => '(E)dit', handler => 'edit' },
546 { name => '(I)gnore', handler => 'none' },
547 { name => '(D)one', handler => 'done' },
549 return $action_choices;
552 sub section_choices {
553 my ($self, $key) = @_;
554 state $section_choices = [
555 # Headline stuff that should go first
557 name => 'Core (E)nhancements',
561 name => 'Securit(y)',
565 name => '(I)ncompatible Changes',
569 name => 'Dep(r)ecations',
573 name => '(P)erformance Enhancements',
577 # Details on things installed with Perl (for Perl developers)
579 name => '(M)odules and Pragmata',
580 handler => 'subsection',
583 name => '(N)ew Modules and Pragmata',
587 name => '(U)pdated Modules and Pragmata',
591 name => '(R)emoved Modules and Pragmata',
597 name => '(D)ocumentation',
598 handler => 'subsection',
601 name => '(N)ew Documentation',
602 handler => 'linked_item',
605 name => '(C)hanges to Existing Documentation',
606 handler => 'linked_item',
611 name => 'Dia(g)nostics',
612 handler => 'subsection',
615 name => '(N)ew Diagnostics',
619 name => '(C)hanges to Existing Diagnostics',
625 name => '(U)tilities',
626 handler => 'linked_item',
629 # Details on building/testing Perl (for porters and packagers)
631 name => '(C)onfiguration and Compilation',
635 name => '(T)esting', # new tests or significant notes about it
639 name => 'Pl(a)tform Support',
640 handler => 'subsection',
643 name => '(N)ew Platforms',
644 handler => 'platform',
647 name => '(D)iscontinued Platforms',
648 handler => 'platform',
651 name => '(P)latform-Specific Notes',
652 handler => 'platform',
657 # Details on perl internals (for porters and XS developers)
659 name => 'Inter(n)al Changes',
663 # Bugs fixed and related stuff
665 name => 'Selected Bug (F)ixes',
669 name => 'Known Prob(l)ems',
673 # dummy options for special handling
676 handler => 'special',
683 return $section_choices;
690 for my $c ( @{ $self->section_choices } ) {
691 if ( $c->{subsection} ) {
692 push @order, map { $_->{name} } @{$c->{subsection}};
695 push @order, $c->{name};
702 #--------------------------------------------------------------------------#
704 #--------------------------------------------------------------------------#
712 sub _prepend_comment {
714 return join ("\n", map { s/^/# /g; $_ } split "\n", $text);
717 sub _strip_comments {
719 return join ("\n", grep { ! /^#/ } split "\n", $text);
722 #--------------------------------------------------------------------------#
723 # Extend Git::Wrapper::Log
724 #--------------------------------------------------------------------------#
726 package Git::Wrapper::XLog;
727 BEGIN { our @ISA = qw/Git::Wrapper::Log/; }
729 sub subject { shift->attr->{subject} }
730 sub body { shift->attr->{body} }
731 sub short_id { shift->attr->{short_id} }
734 my ($class, $log) = @_;
736 my $msg = $log->message;
737 my ($subject, $body) = $msg =~ m{^([^\n]+)\n*(.*)}ms;
740 $body =~ s/[\r\n]*\z//ms;
742 my ($short) = Git::Wrapper->new(".")->rev_parse({short => 1}, $log->id);
744 $log->attr->{subject} = $subject;
745 $log->attr->{body} = $body;
746 $log->attr->{short_id} = $short;
747 return bless $log, $class;
752 my @notes = eval { Git::Wrapper->new(".")->notes('show', $self->id) };
753 pop @notes while @notes && $notes[-1] =~ m{^\s*$};
754 return unless @notes;
755 return join ("\n", @notes);
762 git-deltatool - Annotate commits for perldelta
766 # annotate commits back to last 'git describe' tag
772 $ git-deltatool --mode review
774 # review commits needing help
776 $ git-deltatool --mode review --type blocking
778 # summarize commits needing help
780 $ git-deltatool --mode summary --type blocking
782 # assemble annotations by section to STDOUT
784 $ git-deltatool --mode render
786 # Get a list of commits needing further review, e.g. for peer review
788 $ git-deltatool --mode summary --type blocking
790 # mark 'pending' annotations as 'done' (i.e. added to perldelta)
792 $ git-deltatool --mode update --type pending --status done
798 =item B<--mode>|B<-m> MODE
800 Indicates the run mode for the program. The default is 'assign' which
801 assigns categories and marks the notes as 'pending' (or 'ignored'). Other
802 modes are 'review', 'render', 'summary' and 'update'.
804 =item B<--type>|B<-t> TYPE
806 Indicates what types of commits to process. The default for 'assign' mode is
807 'new', which processes commits without any perldelta notes. The default for
808 'review', 'summary' and 'render' modes is 'pending'. The options must be set
809 explicitly for 'update' mode.
811 The type 'blocking' is reserved for commits needing further review.
813 =item B<--status>|B<-s> STATUS
815 For 'update' mode only, sets a new status. While there is no restriction,
816 it should be one of 'new', 'pending', 'blocking', 'ignored' or 'done'.
818 =item B<--since> REVISION
820 Defines the boundary for searching git commits. Defaults to the last
821 major tag (as would be given by 'git describe').
831 It would be nice to make some of the structured sections smarter -- e.g.
832 look at changed files in pod/* for Documentation section entries. Likewise
833 it would be nice to collate them during the render phase -- e.g. cluster
834 all platform-specific things properly.
838 David Golden <dagolden@cpan.org>
840 =head1 COPYRIGHT AND LICENSE
842 This software is copyright (c) 2010 by David Golden.
844 This is free software; you can redistribute it and/or modify it under the same
845 terms as the Perl 5 programming language system itself.