#!/usr/bin/perl -w # Maintain "what's cooking" messages my $MASTER = 'master'; # for now use strict; my %reverts = ('next' => { map { $_ => 1 } qw( ) }); %reverts = (); sub phrase_these { my %uniq = (); my (@u) = grep { $uniq{$_}++ == 0 } sort @_; my @d = (); for (my $i = 0; $i < @u; $i++) { push @d, $u[$i]; if ($i == @u - 2) { push @d, " and "; } elsif ($i < @u - 2) { push @d, ", "; } } return join('', @d); } sub describe_relation { my ($topic_info) = @_; my @desc; if (exists $topic_info->{'used'}) { push @desc, ("is used by " . phrase_these(@{$topic_info->{'used'}})); } if (exists $topic_info->{'uses'}) { push @desc, ("uses " . phrase_these(@{$topic_info->{'uses'}})); } if (0 && exists $topic_info->{'shares'}) { push @desc, ("shares commits with " . phrase_these(@{$topic_info->{'shares'}})); } if (!@desc) { return ""; } return "(this branch " . join("; ", @desc) . ".)"; } sub forks_from { my ($topic, $fork, $forkee, @overlap) = @_; my %ovl = map { $_ => 1 } (@overlap, @{$topic->{$forkee}{'log'}}); push @{$topic->{$fork}{'uses'}}, $forkee; push @{$topic->{$forkee}{'used'}}, $fork; @{$topic->{$fork}{'log'}} = (grep { !exists $ovl{$_} } @{$topic->{$fork}{'log'}}); } sub topic_relation { my ($topic, $one, $two) = @_; my $fh; open($fh, '-|', qw(git log --abbrev), "--format=%m %h", "$one...$two", "^$MASTER") or die "$!: open log --left-right"; my (@left, @right); while (<$fh>) { my ($sign, $sha1) = /^(.) (.*)/; if ($sign eq '<') { push @left, $sha1; } elsif ($sign eq '>') { push @right, $sha1; } } close($fh) or die "$!: close log --left-right"; if (!@left) { if (@right) { forks_from($topic, $two, $one); } } elsif (!@right) { forks_from($topic, $one, $two); } else { push @{$topic->{$one}{'shares'}}, $two; push @{$topic->{$two}{'shares'}}, $one; } } =head1 Inspect the current set of topics Returns a hash: $topic = { $branchname => { 'tipdate' => date of the tip commit, 'desc' => description string, 'log' => [ $commit,... ], }, } =cut sub get_commit { my (@base) = ($MASTER, 'next', 'seen'); my $fh; open($fh, '-|', qw(git for-each-ref), "--format=%(refname:short) %(committerdate:iso8601)", "refs/heads/??/*") or die "$!: open for-each-ref"; my @topic; my %topic; while (<$fh>) { chomp; my ($branch, $date) = /^(\S+) (.*)$/; next if ($branch =~ m|^../wip-|); push @topic, $branch; $date =~ s/ .*//; $topic{$branch} = +{ log => [], tipdate => $date, }; } close($fh) or die "$!: close for-each-ref"; my %base = map { $_ => undef } @base; my %commit; my $show_branch_batch = 20; while (@topic) { my @t = (@base, splice(@topic, 0, $show_branch_batch)); my $header_delim = '-' x scalar(@t); my $contain_pat = '.' x scalar(@t); open($fh, '-|', qw(git show-branch --sparse --sha1-name), map { "refs/heads/$_" } @t) or die "$!: open show-branch"; while (<$fh>) { chomp; if ($header_delim) { if (/^$header_delim$/) { $header_delim = undef; } next; } my ($contain, $sha1, $log) = ($_ =~ /^($contain_pat) \[([0-9a-f]+)\] (.*)$/); for (my $i = 0; $i < @t; $i++) { my $branch = $t[$i]; my $sign = substr($contain, $i, 1); next if ($sign eq ' '); next if (substr($contain, 0, 1) ne ' '); if (!exists $commit{$sha1}) { $commit{$sha1} = +{ branch => {}, log => $log, }; } my $co = $commit{$sha1}; if (!exists $reverts{$branch}{$sha1}) { $co->{'branch'}{$branch} = 1; } next if (exists $base{$branch}); push @{$topic{$branch}{'log'}}, $sha1; } } close($fh) or die "$!: close show-branch"; } my %shared; for my $sha1 (keys %commit) { my $sign; my $co = $commit{$sha1}; if (exists $co->{'branch'}{'next'}) { $sign = '+'; } elsif (exists $co->{'branch'}{'seen'}) { $sign = '-'; } else { $sign = '.'; } $co->{'log'} = $sign . ' ' . $co->{'log'}; my @t = (sort grep { !exists $base{$_} } keys %{$co->{'branch'}}); next if (@t < 2); my $t = "@t"; $shared{$t} = 1; } for my $combo (keys %shared) { my @combo = split(' ', $combo); for (my $i = 0; $i < @combo - 1; $i++) { for (my $j = $i + 1; $j < @combo; $j++) { topic_relation(\%topic, $combo[$i], $combo[$j]); } } } open($fh, '-|', qw(git log --first-parent --abbrev), "--format=%ci %h %p :%s", "$MASTER..next") or die "$!: open log $MASTER..next"; while (<$fh>) { my ($date, $commit, $parent, $tips); unless (($date, $commit, $parent, $tips) = /^([-0-9]+) ..:..:.. .\d{4} (\S+) (\S+) ([^:]*):/) { die "Oops: $_"; } for my $tip (split(' ', $tips)) { my $co = $commit{$tip}; next unless ($co->{'branch'}{'next'}); $co->{'merged'} = " (merged to 'next' on $date at $commit)"; } } close($fh) or die "$!: close log $MASTER..next"; for my $branch (keys %topic) { my @log = (); my $n = scalar(@{$topic{$branch}{'log'}}); if (!$n) { delete $topic{$branch}; next; } elsif ($n == 1) { $n = "1 commit"; } else { $n = "$n commits"; } my $d = $topic{$branch}{'tipdate'}; my $head = "* $branch ($d) $n\n"; my @desc; for (@{$topic{$branch}{'log'}}) { my $co = $commit{$_}; if (exists $co->{'merged'}) { push @desc, $co->{'merged'}; } push @desc, $commit{$_}->{'log'}; } if (100 < @desc) { @desc = @desc[0..99]; push @desc, "- ..."; } my $list = join("\n", map { " " . $_ } @desc); # NEEDSWORK: # This is done a bit too early. We grabbed all # under refs/heads/??/* without caring if they are # merged to 'seen' yet, and it is correct because # we want to describe a topic that is in the old # edition that is tentatively kicked out of 'seen'. # However, we do not want to say a topic is used # by a new topic that is not yet in 'seen'! my $relation = describe_relation($topic{$branch}); $topic{$branch}{'desc'} = $head . $list; if ($relation) { $topic{$branch}{'desc'} .= "\n $relation"; } } return \%topic; } sub blurb_text { my ($mon, $year, $issue, $dow, $date, $master_at, $next_at, $text) = @_; my $now_string = localtime; my ($current_dow, $current_mon, $current_date, $current_year) = ($now_string =~ /^(\w+) (\w+) (\d+) [\d:]+ (\d+)$/); $mon ||= $current_mon; $year ||= $current_year; $issue ||= "01"; $dow ||= $current_dow; $date ||= $current_date; $master_at ||= '0' x 40; $next_at ||= '0' x 40; $text ||= <<'EOF'; Here are the topics that have been cooking. Commits prefixed with '-' are only in 'seen' (formerly 'pu'---proposed updates) while commits prefixed with '+' are in 'next'. The ones marked with '.' do not appear in any of the integration branches, but I am still holding onto them. Copies of the source code to Git live in many repositories, and the following is a list of the ones I push into or their mirrors. Some repositories have only a subset of branches. With maint, master, next, seen, todo: git://git.kernel.org/pub/scm/git/git.git/ git://repo.or.cz/alt-git.git/ https://kernel.googlesource.com/pub/scm/git/git/ https://github.com/git/git/ https://gitlab.com/git-vcs/git/ With all the integration branches and topics broken out: https://github.com/gitster/git/ Even though the preformatted documentation in HTML and man format are not sources, they are published in these repositories for convenience (replace "htmldocs" with "manpages" for the manual pages): git://git.kernel.org/pub/scm/git/git-htmldocs.git/ https://github.com/gitster/git-htmldocs.git/ Release tarballs are available at: https://www.kernel.org/pub/software/scm/git/ EOF $text = < [], 'section_data' => {}, 'topic_description' => { $blurb => { desc => undef, text => blurb_text(), }, }, }; } open ($fh, '<', $fn) or die "$!: open $fn"; while (<$fh>) { chomp; s/\s+$//; if ($in_unedited_olde) { if (/^>>$/) { $in_unedited_olde = 0; $_ = " | $_"; } } elsif (/^<<$/) { $in_unedited_olde = 1; } if ($in_unedited_olde) { $_ = " | $_"; } if (defined $section && /^-{20,}$/) { $_ = ""; } if (/^$/) { $last_empty = 1; next; } if (/^\[(.*)\]\s*$/) { $section = $1; $branch = undef; if (!exists $section{$section}) { push @section, $section; $section{$section} = []; } next; } if (defined $section && /^\* (\S+) /) { $branch = $1; $last_empty = 0; if (!exists $branch{$branch}) { push @branch, [$branch, $section]; $branch{$branch} = 1; } push @{$section{$section}}, $branch; } if (defined $branch) { my $was_last_empty = $last_empty; $last_empty = 0; if (!exists $description{$branch}) { $description{$branch} = []; } if ($was_last_empty) { push @{$description{$branch}}, ""; } push @{$description{$branch}}, $_; } } close($fh); my $lead = " "; for my $branch (keys %description) { my $ary = $description{$branch}; if ($branch eq $blurb) { while (@{$ary} && $ary->[-1] =~ /^-{30,}$/) { pop @{$ary}; } $description{$branch} = +{ desc => undef, text => join("\n", @{$ary}), }; } else { my @desc = (); while (@{$ary}) { my $elem = shift @{$ary}; last if ($elem eq ''); push @desc, $elem; } my @txt = map { s/^\s+//; $_ = "$lead$_"; s/\s+$//; $_; } @{$ary}; $description{$branch} = +{ desc => join("\n", @desc), text => join("\n", @txt), }; } } return +{ section_list => \@section, section_data => \%section, topic_description => \%description, }; } sub write_cooking { my ($fn, $cooking) = @_; my $fh; open($fh, '>', $fn) or die "$!: open $fn"; print $fh $cooking->{'topic_description'}{$blurb}{'text'}; for my $section_name (@{$cooking->{'section_list'}}) { my $topic_list = $cooking->{'section_data'}{$section_name}; next if (!@{$topic_list}); print $fh "\n"; print $fh '-' x 50, "\n"; print $fh "[$section_name]\n"; my $lead = "\n"; for my $topic (@{$topic_list}) { my $d = $cooking->{'topic_description'}{$topic}; print $fh $lead, $d->{'desc'}, "\n"; if ($d->{'text'}) { # Final clean-up. No leading or trailing # blank lines, no multi-line gaps. for ($d->{'text'}) { s/^\n+//s; s/\n{3,}/\n\n/s; s/\n+$//s; } print $fh "\n", $d->{'text'}, "\n"; } $lead = "\n\n"; } } close($fh); } my $graduated = "Graduated to '$MASTER'"; my $new_topics = 'New Topics'; my $discarded = 'Discarded'; my $cooking_topics = 'Cooking'; sub update_issue { my ($cooking) = @_; my ($fh, $master_at, $next_at, $incremental); open($fh, '-|', qw(git for-each-ref), "--format=%(refname:short) %(objectname)", "refs/heads/$MASTER", "refs/heads/next") or die "$!: open for-each-ref"; while (<$fh>) { my ($branch, $at) = /^(\S+) (\S+)$/; if ($branch eq $MASTER) { $master_at = $at; } if ($branch eq 'next') { $next_at = $at; } } close($fh) or die "$!: close for-each-ref"; $incremental = ((-r "Meta/whats-cooking.txt") && system("cd Meta && " . "git diff --quiet --no-ext-diff HEAD -- " . "whats-cooking.txt")); my $now_string = localtime; my ($current_dow, $current_mon, $current_date, $current_year) = ($now_string =~ /^(\w+) (\w+) +(\d+) [\d:]+ (\d+)$/); my $btext = $cooking->{'topic_description'}{$blurb}{'text'}; if ($btext !~ s/\A$blurb_match//) { die "match pattern broken?"; } my ($mon, $year, $issue, $dow, $date) = ($1, $2, $3, $4, $5); if ($current_mon ne $mon || $current_year ne $year) { $issue = "01"; } elsif (!$incremental) { $issue =~ s/^0*//; $issue = sprintf "%02d", ($issue + 1); } $mon = $current_mon; $year = $current_year; $dow = $current_dow; $date = $current_date; $cooking->{'topic_description'}{$blurb}{'text'} = blurb_text($mon, $year, $issue, $dow, $date, $master_at, $next_at, $btext); # If starting a new issue, move what used to be in # new topics to cooking topics. if (!$incremental) { my $sd = $cooking->{'section_data'}; my $sl = $cooking->{'section_list'}; if (exists $sd->{$new_topics}) { if (!exists $sd->{$cooking_topics}) { $sd->{$cooking_topics} = []; unshift @{$sl}, $cooking_topics; } unshift @{$sd->{$cooking_topics}}, @{$sd->{$new_topics}}; } $sd->{$new_topics} = []; } return $incremental; } sub topic_in_seen { my ($topic_desc) = @_; for my $line (split(/\n/, $topic_desc)) { if ($line =~ /^ [+-] /) { return 1; } } return 0; } my $mergetomaster; sub tweak_willdo { my ($td) = @_; my $desc = $td->{'desc'}; my $text = $td->{'text'}; if (!defined $mergetomaster) { my $master = `git describe $MASTER`; if ($master =~ /-rc\d+(-\d+-g[0-9a-f]+)?$/) { $mergetomaster = "Will cook in 'next'."; } else { $mergetomaster = "Will merge to '$MASTER'."; } } # If updated description (i.e. the list of patches with # merge trail to 'next') has 'merged to next', then # tweak the topic to be slated to 'master'. # NEEDSWORK: does this work correctly for a half-merged topic? $desc =~ s/\n<<\n.*//s; if ($desc =~ /^ \(merged to 'next'/m) { $text =~ s/^ Will merge to 'next'\.$/ $mergetomaster/m; $text =~ s/^ Will merge to and (then )?cook in 'next'\.$/ Will cook in 'next'./m; $text =~ s/^ Will merge to 'next' and (then )?to '$MASTER'\.$/ Will merge to '$MASTER'./m; } $td->{'text'} = $text; } sub tweak_graduated { my ($td) = @_; # Remove the "Will merge" marker from topics that have graduated. for ($td->{'text'}) { s/\n Will merge to '$MASTER'\.(\n|$)//s; } } sub merge_cooking { my ($cooking, $current) = @_; # A hash to find with a branch name or $blurb my $td = $cooking->{'topic_description'}; # A hash to find a list of $td element given a section name my $sd = $cooking->{'section_data'}; # A list of section names my $sl = $cooking->{'section_list'}; my (@new_topic, @gone_topic); # Make sure "New Topics" and "Graduated" exists if (!exists $sd->{$new_topics}) { $sd->{$new_topics} = []; unshift @{$sl}, $new_topics; } if (!exists $sd->{$graduated}) { $sd->{$graduated} = []; unshift @{$sl}, $graduated; } my $incremental = update_issue($cooking); for my $topic (sort keys %{$current}) { if (!exists $td->{$topic}) { # Ignore new topics without anything merged if (topic_in_seen($current->{$topic}{'desc'})) { push @new_topic, $topic; } next; } # Annotate if the contents of the topic changed my $n = $current->{$topic}{'desc'}; my $o = $td->{$topic}{'desc'}; if ($n ne $o) { $td->{$topic}{'desc'} = $n . "\n<<\n" . $o ."\n>>"; tweak_willdo($td->{$topic}); } } for my $topic (sort keys %{$td}) { next if ($topic eq $blurb); next if (!$incremental && grep { $topic eq $_ } @{$sd->{$graduated}}); next if (grep { $topic eq $_ } @{$sd->{$discarded}}); if (!exists $current->{$topic}) { push @gone_topic, $topic; } } for (@new_topic) { push @{$sd->{$new_topics}}, $_; $td->{$_}{'desc'} = $current->{$_}{'desc'}; } if (!$incremental) { $sd->{$graduated} = []; } if (@gone_topic) { for my $topic (@gone_topic) { for my $section (@{$sl}) { my $pre = scalar(@{$sd->{$section}}); @{$sd->{$section}} = (grep { $_ ne $topic } @{$sd->{$section}}); my $post = scalar(@{$sd->{$section}}); next if ($pre == $post); } } for (@gone_topic) { push @{$sd->{$graduated}}, $_; tweak_graduated($td->{$_}); } } } ################################################################ # WilDo sub wildo_queue { my ($what, $action, $topic) = @_; if (!exists $what->{$action}) { $what->{$action} = []; } push @{$what->{$action}}, $topic; } sub section_action { my ($section) = @_; if ($section) { for ($section) { return if (/^Graduated to/ || /^Discarded$/); return $_ if (/^Stalled$/); } } return "Undecided"; } sub wildo_flush_topic { my ($in_section, $what, $topic) = @_; if (defined $topic) { my $action = section_action($in_section); if ($action) { wildo_queue($what, $action, $topic); } } } sub wildo_match { # NEEDSWORK: unify with Reintegrate::annotate_merge if (/^Will (?:\S+ ){0,2}(fast-track|hold|keep|merge|drop|discard|cook|kick|defer|eject|be re-?rolled|wait)[,. ]/ || /^Not urgent/ || /^Not ready/ || /^Waiting for / || /^Can wait in / || /^Still / || /^Stuck / || /^On hold/ || /^Needs? / || /^Expecting / || /^May want to /) { return 1; } if (/^I think this is ready for /) { return 1; } return 0; } sub wildo { my $fd = shift; my (%what, $topic, $last_merge_to_next, $in_section, $in_desc); my $too_recent = '9999-99-99'; while (<$fd>) { chomp; if (/^\[(.*)\]$/) { my $old_section = $in_section; $in_section = $1; wildo_flush_topic($old_section, \%what, $topic); $topic = $in_desc = undef; next; } if (/^\* (\S+) \(([-0-9]+)\) (\d+) commits?$/) { wildo_flush_topic($in_section, \%what, $topic); # tip-date, next-date, topic, count, seen-count $topic = [$2, $too_recent, $1, $3, 0]; $in_desc = undef; next; } if (defined $topic && ($topic->[1] eq $too_recent) && ($topic->[4] == 0) && (/^ \(merged to 'next' on ([-0-9]+)/)) { $topic->[1] = $1; } if (defined $topic && /^ - /) { $topic->[4]++; } if (defined $topic && /^$/) { $in_desc = 1; next; } next unless defined $topic && $in_desc; s/^\s+//; if (wildo_match($_)) { wildo_queue(\%what, $_, $topic); $topic = $in_desc = undef; } if (/Originally merged to 'next' on ([-0-9]+)/) { $topic->[1] = $1; } } wildo_flush_topic($in_section, \%what, $topic); my $ipbl = ""; for my $what (sort keys %what) { print "$ipbl$what\n"; for $topic (sort { (($a->[1] cmp $b->[1]) || ($a->[0] cmp $b->[0])) } @{$what{$what}}) { my ($tip, $next, $name, $count, $seen) = @$topic; my ($sign); $tip =~ s/^\d{4}-//; if (($next eq $too_recent) || (0 < $seen)) { $sign = "-"; $next = " " x 6; } else { $sign = "+"; $next =~ s|^\d{4}-|/|; } $count = "#$count"; printf " %s %-60s %s%s %5s\n", $sign, $name, $tip, $next, $count; } $ipbl = "\n"; } } ################################################################ # HavDone sub havedone_show { my $topic = shift; my $str = shift; my $prefix = " * "; $str =~ s/\A\n+//; $str =~ s/\n+\Z//; print "($topic)\n"; for $str (split(/\n/, $str)) { print "$prefix$str\n"; $prefix = " "; } } sub havedone_count { my @range = @_; my $cnt = `git rev-list --count @range`; chomp $cnt; return $cnt; } sub havedone { my $fh; my %topic = (); my @topic = (); my ($topic, $to_maint, %to_maint, %merged, $in_desc); if (!@ARGV) { open($fh, '-|', qw(git rev-list --first-parent -1), $MASTER, qw(-- Documentation/RelNotes RelNotes)) or die "$!: open rev-list"; my ($rev) = <$fh>; close($fh) or die "$!: close rev-list"; chomp $rev; @ARGV = ("$rev..$MASTER"); } open($fh, '-|', qw(git log --first-parent --oneline --reverse), @ARGV) or die "$!: open log --first-parent"; while (<$fh>) { my ($sha1, $branch) = /^([0-9a-f]+) Merge branch '(.*)'$/; next unless $branch; $topic{$branch} = ""; $merged{$branch} = $sha1; push @topic, $branch; } close($fh) or die "$!: close log --first-parent"; open($fh, "<", "Meta/whats-cooking.txt") or die "$!: open whats-cooking"; while (<$fh>) { chomp; if (/^\[(.*)\]$/) { # section header $in_desc = $topic = undef; next; } if (/^\* (\S+) \([-0-9]+\) \d+ commits?$/) { if (exists $topic{$1}) { $topic = $1; $to_maint = 0; } else { $in_desc = $topic = undef; } next; } if (defined $topic && /^$/) { $in_desc = 1; next; } next unless defined $topic && $in_desc; s/^\s+//; if (wildo_match($_)) { next; } $topic{$topic} .= "$_\n"; } close($fh) or die "$!: close whats-cooking"; for $topic (@topic) { my $merged = $merged{$topic}; my $in_master = havedone_count("$merged^1..$merged^2"); my $not_in_maint = havedone_count("maint..$merged^2"); if ($in_master == $not_in_maint) { $to_maint{$topic} = 1; } } my $shown = 0; for $topic (@topic) { next if (exists $to_maint{$topic}); havedone_show($topic, $topic{$topic}); print "\n"; $shown++; } if ($shown) { print "-" x 64, "\n"; } for $topic (@topic) { next unless (exists $to_maint{$topic}); havedone_show($topic, $topic{$topic}); my $sha1 = `git rev-parse --short $topic`; chomp $sha1; print " (merge $sha1 $topic later to maint).\n"; print "\n"; } } ################################################################ # WhatsCooking sub doit { my $topic = get_commit(); my $cooking = read_previous('Meta/whats-cooking.txt'); merge_cooking($cooking, $topic); write_cooking('Meta/whats-cooking.txt', $cooking); } ################################################################ # Main use Getopt::Long; my ($wildo, $havedone); if (!GetOptions("wildo" => \$wildo, "havedone" => \$havedone)) { print STDERR "$0 [--wildo|--havedone]\n"; exit 1; } if ($wildo) { my $fd; if (!@ARGV) { open($fd, "<", "Meta/whats-cooking.txt"); } elsif (@ARGV != 1) { print STDERR "$0 --wildo [filename|HEAD|-]\n"; exit 1; } elsif ($ARGV[0] eq '-') { $fd = \*STDIN; } elsif ($ARGV[0] =~ /^HEAD/) { open($fd, "-|", qw(git --git-dir=Meta/.git cat-file -p), "$ARGV[0]:whats-cooking.txt"); } elsif ($ARGV[0] eq ":") { open($fd, "-|", qw(git --git-dir=Meta/.git cat-file -p), ":whats-cooking.txt"); } else { open($fd, "<", $ARGV[0]); } wildo($fd); } elsif ($havedone) { havedone(); } else { doit(); }