You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@subversion.apache.org by pr...@apache.org on 2013/07/09 10:48:47 UTC

svn commit: r1501136 [10/10] - in /subversion/branches/verify-keep-going: ./ build/ build/ac-macros/ build/generator/ subversion/bindings/javahl/native/ subversion/bindings/javahl/src/org/apache/subversion/javahl/ subversion/bindings/javahl/src/org/apa...

Modified: subversion/branches/verify-keep-going/tools/dist/backport.pl
URL: http://svn.apache.org/viewvc/subversion/branches/verify-keep-going/tools/dist/backport.pl?rev=1501136&r1=1501135&r2=1501136&view=diff
==============================================================================
--- subversion/branches/verify-keep-going/tools/dist/backport.pl (original)
+++ subversion/branches/verify-keep-going/tools/dist/backport.pl Tue Jul  9 08:48:43 2013
@@ -1,4 +1,4 @@
-#!/usr/bin/perl -l
+#!/usr/bin/perl
 use warnings;
 use strict;
 use feature qw/switch say/;
@@ -20,65 +20,142 @@ use feature qw/switch say/;
 # specific language governing permissions and limitations
 # under the License.
 
+use Digest ();
 use Term::ReadKey qw/ReadMode ReadKey/;
+use File::Copy qw/copy move/;
 use File::Temp qw/tempfile/;
 use POSIX qw/ctermid/;
 
+############### Start of reading values from environment ###############
+
+# Programs we use.
 my $SVN = $ENV{SVN} || 'svn'; # passed unquoted to sh
+my $SHELL = $ENV{SHELL} // '/bin/sh';
 my $VIM = 'vim';
-my $STATUS = './STATUS';
-my $BRANCHES = '^/subversion/branches';
+my $EDITOR = $ENV{SVN_EDITOR} // $ENV{VISUAL} // $ENV{EDITOR} // 'ed';
+my $PAGER = $ENV{PAGER} // 'less -F' // 'cat';
 
-my $YES = $ENV{YES}; # batch mode: eliminate prompts, add sleeps
-my $MAY_COMMIT = qw[false true][0];
-my $DEBUG = qw[false true][0]; # 'set -x', etc
-$DEBUG = 'true' if exists $ENV{DEBUG};
+# Mode flags.
+#    svn-role:      YES=1 MAY_COMMIT=1
+#    conflicts-bot: YES=1 MAY_COMMIT=0
+#    interactive:   YES=0 MAY_COMMIT=0      (default)
+my $YES = ($ENV{YES} // 0) ? 1 : 0; # batch mode: eliminate prompts, add sleeps
+my $MAY_COMMIT = 'false';
 $MAY_COMMIT = 'true' if ($ENV{MAY_COMMIT} // "false") =~ /^(1|yes|true)$/i;
 
-# derived values
+# Other knobs.
+my $VERBOSE = 0;
+my $DEBUG = (exists $ENV{DEBUG}) ? 'true' : 'false'; # 'set -x', etc
+
+# Username for entering votes.
+my ($AVAILID) = $ENV{AVAILID} // do {
+  my $SVN_A_O_REALM = 'd3c8a345b14f6a1b42251aef8027ab57';
+  open USERNAME, '<', "$ENV{HOME}/.subversion/auth/svn.simple/$SVN_A_O_REALM";
+  1 until <USERNAME> eq "username\n";
+  <USERNAME>;
+  local $_ = <USERNAME>;
+  chomp;
+  $_
+}
+// warn "Username for commits (of votes/merges) not found";
+
+############## End of reading values from the environment ##############
+
+# Constants.
+my $STATUS = './STATUS';
+my $STATEFILE = './.backports1';
+my $BRANCHES = '^/subversion/branches';
+
+# Globals.
+my %ERRORS = ();
+my $MERGED_SOMETHING = 0;
 my $SVNq;
+
+# Derived values.
 my $SVNvsn = do {
   my ($major, $minor, $patch) = `$SVN --version -q` =~ /^(\d+)\.(\d+)\.(\d+)/;
   1e6*$major + 1e3*$minor + $patch;
 };
-
 $SVN .= " --non-interactive" if $YES or not defined ctermid;
 $SVNq = "$SVN -q ";
 $SVNq =~ s/-q// if $DEBUG eq 'true';
 
+
 sub usage {
   my $basename = $0;
   $basename =~ s#.*/##;
   print <<EOF;
-Run this from the root of your release branch (e.g., 1.6.x) working copy.  Use
-a working copy 'svn revert -R .' can be run on at any time, as this script
-will run revert prior to every merge.
-
-For each entry in STATUS, you will be prompted whether to merge it.  The
-merge will not be committed.
+backport.pl: a tool for reviewing and merging STATUS entries.  Run this with
+CWD being the root of the stable branch (e.g., 1.8.x).  The ./STATUS file
+should be at HEAD.
+
+In interactive mode (the default), you will be prompted once per STATUS entry.
+At a prompt, you have the following options:
+
+y:   Run a merge.  It will not be committed.
+     WARNING: This will run 'update' and 'revert -R ./'.
+l:   Show logs for the entries being nominated.
+q:   Quit the "for each nomination" loop.
+±1:  Enter a +1 or -1 vote
+     You will be prompted to commit your vote at the end.
+±0:  Enter a +0 or -0 vote
+     You will be prompted to commit your vote at the end.
+a:   Move the entry to the "Approved changes" section.
+     When both approving and voting on an entry, approve first: for example,
+     to enter a third +1 vote, type "a" "+" "1".
+e:   Edit the entry in $EDITOR.
+     You will be prompted to commit your edits at the end.
+N:   Move to the next entry.  Cache the entry in '$STATEFILE' and do not
+     prompt for it again (even across runs) until it is changed.
+ :   Move to the next entry, without adding the current one to the cache.
+     (That's a space character, ASCII 0x20.)
+
+After running a merge, you have the following options:
+
+y:   Open a shell.
+d:   View a diff.
+N:   Move to the next entry.
+
+There is also a batch mode: when \$YES and \$MAY_COMMIT are defined to '1' i
+the environment, this script will iterate the "Approved:" section, and merge
+and commit each entry therein.  If only \$YES is defined, the script will
+merge every nomination (including unapproved and vetoed ones), and complain
+to stderr if it notices any conflicts.  These mode are normally used by the
+'svn-role' cron job and/or buildbot, not by human users.
 
 The 'svn' binary defined by the environment variable \$SVN, or otherwise the
 'svn' found in \$PATH, will be used to manage the working copy.
 EOF
 }
 
+sub digest_string {
+  Digest->new("MD5")->add(@_)->hexdigest
+}
+
 sub prompt {
-  local $\; # disable 'perl -l' effects
-  print "$_[0] "; shift;
+  print $_[0]; shift;
   my %args = @_;
+  my $getchar = sub {
+    ReadMode 'cbreak';
+    my $answer = (ReadKey 0);
+    ReadMode 'normal';
+    print $answer;
+    return $answer;
+  };
 
   die "$0: called prompt() in non-interactive mode!" if $YES;
-  ReadMode 'cbreak';
-  my $answer = (ReadKey 0);
-  ReadMode 'restore';
-  print $answer, "\n";
+  my $answer = $getchar->();
+  $answer .= $getchar->() if exists $args{extra} and $answer =~ $args{extra};
+  say "" unless $args{dontprint};
   return $args{verbose}
          ? $answer
          : ($answer =~ /^y/i) ? 1 : 0;
 }
 
+
 sub merge {
   my %entry = @_;
+  $MERGED_SOMETHING++;
 
   my ($logmsg_fh, $logmsg_filename) = tempfile();
   my ($mergeargs, $pattern);
@@ -90,26 +167,26 @@ sub merge {
     $pattern = sprintf '\V\(%s branch(es)?\|branches\/%s\|Branch\(es\)\?: \*\n\? \*%s\)', $entry{branch}, $entry{branch}, $entry{branch};
     if ($SVNvsn >= 1_008_000) {
       $mergeargs = "$BRANCHES/$entry{branch}";
-      print $logmsg_fh "Merge the $entry{header}:";
+      say $logmsg_fh "Merge the $entry{header}:";
     } else {
       $mergeargs = "--reintegrate $BRANCHES/$entry{branch}";
-      print $logmsg_fh "Reintegrate the $entry{header}:";
+      say $logmsg_fh "Reintegrate the $entry{header}:";
     }
-    print $logmsg_fh "";
+    say $logmsg_fh "";
   } elsif (@{$entry{revisions}}) {
     $pattern = '^ [*] \V' . 'r' . $entry{revisions}->[0];
     $mergeargs = join " ", (map { "-c$_" } @{$entry{revisions}}), '^/subversion/trunk';
     if (@{$entry{revisions}} > 1) {
-      print $logmsg_fh "Merge the $entry{header} from trunk:";
-      print $logmsg_fh "";
+      say $logmsg_fh "Merge the $entry{header} from trunk:";
+      say $logmsg_fh "";
     } else {
-      print $logmsg_fh "Merge r$entry{revisions}->[0] from trunk:";
-      print $logmsg_fh "";
+      say $logmsg_fh "Merge r$entry{revisions}->[0] from trunk:";
+      say $logmsg_fh "";
     }
   } else {
     die "Don't know how to call $entry{header}";
   }
-  print $logmsg_fh $_ for @{$entry{entry}};
+  say $logmsg_fh $_ for @{$entry{entry}};
   close $logmsg_fh or die "Can't close $logmsg_filename: $!";
 
   my $reintegrated_word = ($SVNvsn >= 1_008_000) ? "merged" : "reintegrated";
@@ -120,9 +197,11 @@ if $DEBUG; then
   set -x
 fi
 $SVN diff > $backupfile
-cp STATUS STATUS.$$
+if ! $MAY_COMMIT ; then
+  cp STATUS STATUS.$$
+fi
 $SVNq revert -R .
-if $MAY_COMMIT ; then
+if ! $MAY_COMMIT ; then
   mv STATUS.$$ STATUS
 fi
 $SVNq up
@@ -140,7 +219,7 @@ fi
 if $MAY_COMMIT; then
   $VIM -e -s -n -N -i NONE -u NONE -c '/$pattern/normal! dap' -c wq $STATUS
   $SVNq commit -F $logmsg_filename
-else
+elif test 1 -ne $YES; then
   echo "Would have committed:"
   echo '[[['
   $SVN status -q
@@ -157,16 +236,22 @@ if $MAY_COMMIT; then
   if [ -n "\$YES" ]; then sleep 15; fi
   $SVNq rm $BRANCHES/$entry{branch} -m "Remove the '$entry{branch}' branch, $reintegrated_word in r\$reinteg_rev."
   if [ -n "\$YES" ]; then sleep 1; fi
-else
-  echo "Removing $reintegrated_word '$entry{branch}' branch"
+elif test 1 -ne $YES; then
+  echo "Would remove $reintegrated_word '$entry{branch}' branch"
 fi
 EOF
 
   open SHELL, '|-', qw#/bin/sh# or die "$! (in '$entry{header}')";
   print SHELL $script;
   close SHELL or warn "$0: sh($?): $! (in '$entry{header}')";
+  $ERRORS{$entry{id}} = "sh($?): $!" if $?;
+
+  if (-z $backupfile) {
+    unlink $backupfile;
+  } else {
+    warn "Local mods saved to '$backupfile'\n";
+  }
 
-  unlink $backupfile if -z $backupfile;
   unlink $logmsg_filename unless $? or $!;
 }
 
@@ -180,7 +265,9 @@ sub sanitize_branch {
 
 # TODO: may need to parse other headers too?
 sub parse_entry {
+  my $raw = shift;
   my @lines = @_;
+  my $depends;
   my (@revisions, @logsummary, $branch, @votes);
   # @lines = @_;
 
@@ -192,19 +279,27 @@ sub parse_entry {
   $branch = sanitize_branch $1
     if $_[0] =~ /^(\S*) branch$/ or $_[0] =~ m#branches/(\S+)#;
   while ($_[0] =~ /^r/) {
+    my $sawrevnum = 0;
     while ($_[0] =~ s/^r(\d+)(?:$|[,; ]+)//) {
       push @revisions, $1;
+      $sawrevnum++;
     }
-    shift;
+    $sawrevnum ? shift : last;
   }
 
   # summary
-  push @logsummary, shift until $_[0] =~ /^\s*\w+:/ or not defined $_[0];
+  do {
+    push @logsummary, shift
+  } until $_[0] =~ /^\s*\w+:/ or not defined $_[0];
 
   # votes
   unshift @votes, pop until $_[-1] =~ /^\s*Votes:/ or not defined $_[-1];
   pop;
 
+  # depends
+  # TODO: parse the value of this.
+  $depends = grep /^Depends:/, @_;
+
   # branch
   while (@_) {
     shift and next unless $_[0] =~ s/^\s*Branch(es)?:\s*//;
@@ -212,9 +307,11 @@ sub parse_entry {
   }
 
   # Compute a header.
-  my $header;
+  my ($header, $id);
   $header = "r$revisions[0] group" if @revisions;
-  $header = "$branch branch" if $branch;
+  $id = "r$revisions[0]"           if @revisions;
+  $header = "$branch branch"       if $branch;
+  $id = $branch                    if $branch;
   warn "No header for [@lines]" unless $header;
 
   return (
@@ -222,49 +319,310 @@ sub parse_entry {
     logsummary => [@logsummary],
     branch => $branch,
     header => $header,
+    depends => $depends,
+    id => $id,
     votes => [@votes],
     entry => [@lines],
+    raw => $raw,
+    digest => digest_string($raw),
   );
 }
 
+sub edit_string {
+  # Edits $_[0] in an editor.
+  # $_[1] is used in error messages.
+  die "$0: called edit_string() in non-interactive mode!" if $YES;
+  my $string = shift;
+  my $name = shift;
+  my %args = @_;
+  my $trailing_eol = $args{trailing_eol};
+  my ($fh, $fn) = tempfile;
+  print $fh $string;
+  $fh->flush or die $!;
+  system("$EDITOR -- $fn") == 0
+    or warn "\$EDITOR failed editing $name: $! ($?); "
+           ."edit results ($fn) ignored.";
+  my $rv = `cat $fn`;
+  $rv =~ s/\n*\z// and $rv .= ("\n" x $trailing_eol) if defined $trailing_eol;
+  $rv;
+}
+
+sub vote {
+  my ($state, $approved, $votes) = @_;
+  my (%approvedcheck, %votescheck);
+  my $raw_approved = "";
+  my @votes;
+  return unless %$approved or %$votes;
+
+  my $had_empty_line;
+
+  $. = 0;
+  open STATUS, "<", $STATUS;
+  open VOTES, ">", "$STATUS.$$.tmp";
+  while (<STATUS>) {
+    $had_empty_line = /\n\n\z/;
+    my $key = digest_string $_;
+
+    $approvedcheck{$key}++ if exists $approved->{$key};
+    $votescheck{$key}++ if exists $votes->{$key};
+
+    unless (exists $votes->{$key}) {
+      (exists $approved->{$key}) ? ($raw_approved .= $_) : (print VOTES);
+      next;
+    }
+
+    my ($vote, $entry) = @{$votes->{$key}};
+    push @votes, [$vote, $entry, undef]; # ->[2] later set to $digest
+
+    if ($vote eq 'edit') {
+      local $_ = $entry->{raw};
+      (exists $approved->{$key}) ? ($raw_approved .= $_) : (print VOTES);
+      next;
+    }
+    
+    s/^(\s*\Q$vote\E:.*)/"$1, $AVAILID"/me
+    or s/(.*\w.*?\n)/"$1     $vote: $AVAILID\n"/se;
+    $_ = edit_string $_, $entry->{header}, trailing_eol => 2
+        if $vote ne '+1';
+    $votes[$#votes]->[2] = digest_string $_;
+    (exists $approved->{$key}) ? ($raw_approved .= $_) : (print VOTES);
+  }
+  close STATUS;
+  print VOTES "\n" if $raw_approved and !$had_empty_line;
+  print VOTES $raw_approved;
+  close VOTES;
+  die "Some vote chunks weren't found: ",
+    map $votes->{$_}->[1]->{id},
+    grep { !$votescheck{$_} } keys %$votes
+    if scalar(keys %$votes) != scalar(keys %votescheck);
+  die "Some approval chunks weren't found: ",
+    map $approved->{$_}->{id},
+    grep { !$approvedcheck{$_} } keys %$approved
+    if scalar(keys %$approved) != scalar(keys %approvedcheck);
+  move "$STATUS.$$.tmp", $STATUS;
+
+  my $logmsg = do {
+    my %allkeys = map { $_ => 1 } keys(%$votes), keys(%$approved);
+    my @sentences = map {
+       exists $votes->{$_}
+       ? (
+         ( $votes->{$_}->[0] eq 'edit'
+           ? "Edit the $votes->{$_}->[1]->{id} entry"
+           : "Vote $votes->{$_}->[0] on the $votes->{$_}->[1]->{header}"
+         )
+         . (exists $approved->{$_} ? ", approving" : "")
+         . "."
+         )
+      : # exists only in $approved
+        "Approve the $approved->{$_}->{header}."
+      } keys %allkeys;
+    (@sentences == 1)
+    ? $sentences[0]
+    : "* STATUS:\n" . join "", map "  $_\n", @sentences;
+  };
+
+  system "$SVN diff -- $STATUS";
+  say "Voting '$_->[0]' on $_->[1]->{id}." for @votes;
+  # say $logmsg;
+  if (prompt "Commit these votes? ") {
+    my ($logmsg_fh, $logmsg_filename) = tempfile();
+    print $logmsg_fh $logmsg;
+    close $logmsg_fh;
+    warn "Tempfile name '$logmsg_filename' not shell-safe; "
+         ."refraining from commit.\n"
+        unless $logmsg_filename =~ /^([A-Z0-9._-]|\x2f)+$/i;
+    system("$SVN commit -F $logmsg_filename -- $STATUS") == 0
+        or warn("Committing the votes failed($?): $!") and return;
+    unlink $logmsg_filename;
+
+    $state->{$approved->{$_}->{digest}}++ for keys %$approved;
+    $state->{$_->[2]}++ for @votes;
+  }
+}
+
+sub revert {
+  copy $STATUS, "$STATUS.$$.tmp";
+  system "$SVN revert -q $STATUS";
+  system "$SVN revert -R ./" . ($YES && $MAY_COMMIT ne 'true'
+                             ? " -q" : "");
+  move "$STATUS.$$.tmp", $STATUS;
+}
+
+sub maybe_revert {
+  # This is both a SIGINT handler, and the tail end of main() in normal runs.
+  # @_ is 'INT' in the former case and () in the latter.
+  delete $SIG{INT} unless @_;
+  revert if !$YES and $MERGED_SOMETHING and prompt 'Revert? ';
+  (@_ ? exit : return);
+}
+
+sub warning_summary {
+  return unless %ERRORS;
+
+  warn "Warning summary\n";
+  warn "===============\n";
+  warn "\n";
+  for my $header (keys %ERRORS) {
+    warn "$header: $ERRORS{$header}\n";
+  }
+}
+
+sub read_state {
+  # die "$0: called read_state() in non-interactive mode!" if $YES;
+
+  open my $fh, '<', $STATEFILE or do {
+    return {} if $!{ENOENT};
+    die "Can't read statefile: $!";
+  };
+
+  my %rv;
+  while (<$fh>) {
+    chomp;
+    $rv{$_}++;
+  }
+  return \%rv;
+}
+
+sub write_state {
+  my $state = shift;
+  open STATE, '>', $STATEFILE or warn("Can't write state: $!"), return;
+  say STATE for keys %$state;
+  close STATE;
+}
+
+sub exit_stage_left {
+  my $state = shift;
+  maybe_revert;
+  warning_summary if $YES;
+  vote $state, @_;
+  write_state $state;
+  exit scalar keys %ERRORS;
+}
+
 sub handle_entry {
   my $in_approved = shift;
-  my %entry = parse_entry @_;
+  my $approved = shift;
+  my $votes = shift;
+  my $state = shift;
+  my $raw = shift;
+  my %entry = parse_entry $raw, @_;
   my @vetoes = grep { /^  -1:/ } @{$entry{votes}};
 
   if ($YES) {
-    merge %entry if $in_approved and not @vetoes;
+    # Run a merge if:
+    unless (@vetoes) {
+      if ($MAY_COMMIT eq 'true' and $in_approved) {
+        # svn-role mode
+        merge %entry;
+      } elsif ($MAY_COMMIT ne 'true') {
+        # Scan-for-conflicts mode
+        merge %entry;
+
+        my $output = `$SVN status`;
+        my (@conflicts) = ($output =~ m#^(?:C|.C|...C).*/(.*)#mg);
+        if (@conflicts and !$entry{depends}) {
+          $ERRORS{$entry{id}} //= "Conflicts merging the $entry{header}: "
+                                  . (join ', ', @conflicts);
+          say STDERR "Conflicts merging the $entry{header}!";
+          say STDERR "";
+          say STDERR $output;
+        } elsif (!@conflicts and $entry{depends}) {
+          warn "No conflicts merging the $entry{header}, but conflicts were "
+              ."expected ('Depends:' header set)\n";
+        } elsif (@conflicts) {
+          say "Conflicts found merging $entry{id}, as expected.";
+        }
+        revert;
+      }
+    }
+  } elsif ($state->{$entry{digest}}) {
+    print "\n\n";
+    say "Skipping the $entry{header} (remove $STATEFILE to reset):";
+    say $entry{logsummary}->[0], ('[...]' x (0 < $#{$entry{logsummary}}));
   } else {
-    print "";
-    print "\n>>> The $entry{header}:";
-    print join ", ", map { "r$_" } @{$entry{revisions}};
-    print "$BRANCHES/$entry{branch}" if $entry{branch};
-    print "";
-    print for @{$entry{logsummary}};
-    print "";
-    print for @{$entry{votes}};
-    print "";
-    print "Vetoes found!" if @vetoes;
-
-    if (prompt 'Go ahead?') {
-      merge %entry;
-      MAYBE_DIFF: while (1) { 
-        given (prompt "Shall I open a subshell? [ydN]", verbose => 1) {
-          when (/^y/i) {
-            system($ENV{SHELL} // "/bin/sh") == 0
-              or warn "Creating an interactive subshell failed ($?): $!"
-          }
-          when (/^d/) {
-            system($SVN, 'diff') == 0
-              or warn "diff failed ($?): $!";
-            next;
+    # This loop is just a hack because 'goto' panics.  The goto should be where
+    # the "next PROMPT;" is; there's a "last;" at the end of the loop body.
+    PROMPT: while (1) {
+    say "";
+    say "\n>>> The $entry{header}:";
+    say join ", ", map { "r$_" } @{$entry{revisions}} if @{$entry{revisions}};
+    say "$BRANCHES/$entry{branch}" if $entry{branch};
+    say "";
+    say for @{$entry{logsummary}};
+    say "";
+    say for @{$entry{votes}};
+    say "";
+    say "Vetoes found!" if @vetoes;
+
+    # See above for why the while(1).
+    QUESTION: while (1) {
+    my $key = $entry{digest};
+    given (prompt 'Run a merge? [y,l,±1,±0,q,e,a, ,N] ',
+                   verbose => 1, extra => qr/[+-]/) {
+      when (/^y/i) {
+        merge %entry;
+        while (1) { 
+          given (prompt "Shall I open a subshell? [ydN] ", verbose => 1) {
+            when (/^y/i) {
+              system($SHELL) == 0
+                or warn "Creating an interactive subshell failed ($?): $!"
+            }
+            when (/^d/) {
+              system("$SVN diff | $PAGER") == 0
+                or warn "diff failed ($?): $!";
+              next;
+            }
           }
+          revert;
+          next PROMPT;
+        }
+        # NOTREACHED
+      }
+      when (/^l/i) {
+        if ($entry{branch}) {
+            system "$SVN log --stop-on-copy -v -r 0:HEAD -- "
+                   ."$BRANCHES/$entry{branch} "
+                   ."| $PAGER";
+        } elsif (@{$entry{revisions}}) {
+            system "$SVN log ".(join ' ', map { "-r$_" } @{$entry{revisions}})
+                   ." -- ^/subversion | $PAGER";
+        } else {
+            die "Assertion failed: entry has neither branch nor revisions:\n",
+                '[[[', (join ';;', %entry), ']]]';
         }
-      last;
+        next PROMPT;
+      }
+      when (/^q/i) {
+        exit_stage_left $state, $approved, $votes;
+      }
+      when (/^a/i) {
+        $approved->{$key} = \%entry;
+        next PROMPT;
+      }
+      when (/^([+-][01])\s*$/i) {
+        $votes->{$key} = [$1, \%entry];
+        say "Your '$1' vote has been recorded." if $VERBOSE;
+      }
+      when (/^e/i) {
+        my $original = $entry{raw};
+        $entry{raw} = edit_string $entry{raw}, $entry{header},
+                        trailing_eol => 2;
+        $votes->{$key} = ['edit', \%entry] # marker for the 2nd pass
+            if $original ne $entry{raw};
+      }
+      when (/^N/i) {
+        $state->{$entry{digest}}++;
+      }
+      when (/^\x20/) {
+        last PROMPT; # Fall off the end of the given/when block.
+      }
+      default {
+        say "Please use one of the options in brackets (q to quit)!";
+        next QUESTION;
       }
-      # Don't revert.  The next merge() call will do that anyway, or maybe the
-      # user did in his interactive shell.
     }
+    last; } # QUESTION
+    last; } # PROMPT
   }
 
   # TODO: merge() changes ./STATUS, which we're reading below, but
@@ -273,35 +631,51 @@ sub handle_entry {
   1;
 }
 
+
 sub main {
+  my %approved;
+  my %votes;
+  my $state = read_state;
+
   usage, exit 0 if @ARGV;
 
   open STATUS, "<", $STATUS or (usage, exit 1);
 
   # Because we use the ':normal' command in Vim...
-  die "A vim with the +ex_extra feature is required"
-      if `${VIM} --version` !~ /[+]ex_extra/;
+  die "A vim with the +ex_extra feature is required for \$MAY_COMMIT mode"
+      if $MAY_COMMIT eq 'true' and `${VIM} --version` !~ /[+]ex_extra/;
 
   # ### TODO: need to run 'revert' here
   # ### TODO: both here and in merge(), unlink files that previous merges added
   # When running from cron, there shouldn't be local mods.  (For interactive
   # usage, we preserve local mods to STATUS.)
-  die "Local mods to STATUS file $STATUS" if $YES and `$SVN status -q $STATUS`;
+  system("$SVN info $STATUS >/dev/null") == 0
+    or die "$0: svn error; point \$SVN to an appropriate binary";
+
+  if (`$SVN status -q $STATUS`) {
+    die  "Local mods to STATUS file $STATUS" if $YES;
+    warn "Local mods to STATUS file $STATUS";
+    system "$SVN diff -- $STATUS";
+    prompt "Press the 'any' key to continue...\n", dontprint => 1;
+  }
 
   # Skip most of the file
+  $/ = ""; # paragraph mode
   while (<STATUS>) {
     last if /^Status of \d+\.\d+/;
   }
-  $/ = ""; # paragraph mode
+
+  $SIG{INT} = \&maybe_revert unless $YES;
 
   my $in_approved = 0;
   while (<STATUS>) {
+    my $lines = $_;
     my @lines = split /\n/;
 
     given ($lines[0]) {
       # Section header
       when (/^[A-Z].*:$/i) {
-        print "\n\n=== $lines[0]" unless $YES;
+        say "\n\n=== $lines[0]" unless $YES;
         $in_approved = $lines[0] =~ /^Approved changes/;
       }
       # Comment
@@ -316,15 +690,15 @@ sub main {
       when (/^ \*/) {
         warn "Too many bullets in $lines[0]" and next
           if grep /^ \*/, @lines[1..$#lines];
-        handle_entry $in_approved, @lines;
+        handle_entry $in_approved, \%approved, \%votes, $state, $lines, @lines;
       }
       default {
-        warn "Unknown entry '$lines[0]' at line $.\n";
+        warn "Unknown entry '$lines[0]'";
       }
     }
   }
 
-  system $SVN, qw/revert -R ./ if !$YES and prompt 'Revert? ';
+  exit_stage_left $state, \%approved, \%votes;
 }
 
 &main