Merge branch 'jc/git-add--interactive'
authorJunio C Hamano <junkio@cox.net>
Mon, 25 Dec 2006 09:31:17 +0000 (01:31 -0800)
committerJunio C Hamano <junkio@cox.net>
Mon, 25 Dec 2006 09:31:17 +0000 (01:31 -0800)
* jc/git-add--interactive:
git-add --interactive: add documentation
git-add --interactive: hunk splitting
git-add --interactive

.gitignore
Documentation/git-add.txt
Makefile
builtin-add.c
git-add--interactive.perl [new file with mode: 0755]
index d706dd92c6a63474616c09388fc9744ff055cedc..91e69665f46bdecaedd4a9664e5238448b42467a 100644 (file)
@@ -2,6 +2,7 @@ GIT-CFLAGS
 GIT-VERSION-FILE
 git
 git-add
+git-add--interactive
 git-am
 git-annotate
 git-apply
index d86c0e7f19ee67556b48bb7f94ab9039a0195ea6..8710b3a75e0bfb7d0643a25ee7b9548ea5b01261 100644 (file)
@@ -7,7 +7,7 @@ git-add - Add file contents to the changeset to be committed next
 
 SYNOPSIS
 --------
-'git-add' [-n] [-v] [--] <file>...
+'git-add' [-n] [-v] [--interactive] [--] <file>...
 
 DESCRIPTION
 -----------
@@ -43,6 +43,10 @@ OPTIONS
 -v::
         Be verbose.
 
+\--interactive::
+       Add modified contents in the working tree interactively to
+       the index.
+
 \--::
        This option can be used to separate command-line options from
        the list of files, (useful when filenames might be mistaken
@@ -67,6 +71,119 @@ git-add git-*.sh::
        (i.e. you are listing the files explicitly), it does not
        consider `subdir/git-foo.sh`.
 
+Interactive mode
+----------------
+When the command enters the interactive mode, it shows the
+output of the 'status' subcommand, and then goes into ints
+interactive command loop.
+
+The command loop shows the list of subcommands available, and
+gives a prompt "What now> ".  In general, when the prompt ends
+with a single '>', you can pick only one of the choices given
+and type return, like this:
+
+------------
+    *** Commands ***
+      1: status       2: update       3: revert       4: add untracked
+      5: patch        6: diff         7: quit         8: help
+    What now> 1
+------------
+
+You also could say "s" or "sta" or "status" above as long as the
+choice is unique.
+
+The main command loop has 6 subcommands (plus help and quit).
+
+status::
+
+   This shows the change between HEAD and index (i.e. what will be
+   committed if you say "git commit"), and between index and
+   working tree files (i.e. what you could stage further before
+   "git commit" using "git-add") for each path.  A sample output
+   looks like this:
++
+------------
+              staged     unstaged path
+     1:       binary      nothing foo.png
+     2:     +403/-35        +1/-1 git-add--interactive.perl
+------------
++
+It shows that foo.png has differences from HEAD (but that is
+binary so line count cannot be shown) and there is no
+difference between indexed copy and the working tree
+version (if the working tree version were also different,
+'binary' would have been shown in place of 'nothing').  The
+other file, git-add--interactive.perl, has 403 lines added
+and 35 lines deleted if you commit what is in the index, but
+working tree file has further modifications (one addition and
+one deletion).
+
+update::
+
+   This shows the status information and gives prompt
+   "Update>>".  When the prompt ends with double '>>', you can
+   make more than one selection, concatenated with whitespace or
+   comma.  Also you can say ranges.  E.g. "2-5 7,9" to choose
+   2,3,4,5,7,9 from the list.  You can say '*' to choose
+   everything.
++
+What you chose are then highlighted with '*',
+like this:
++
+------------
+           staged     unstaged path
+  1:       binary      nothing foo.png
+* 2:     +403/-35        +1/-1 git-add--interactive.perl
+------------
++
+To remove selection, prefix the input with `-`
+like this:
++
+------------
+Update>> -2
+------------
++
+After making the selection, answer with an empty line to stage the
+contents of working tree files for selected paths in the index.
+
+revert::
+
+  This has a very similar UI to 'update', and the staged
+  information for selected paths are reverted to that of the
+  HEAD version.  Reverting new paths makes them untracked.
+
+add untracked::
+
+  This has a very similar UI to 'update' and
+  'revert', and lets you add untracked paths to the index.
+
+patch::
+
+  This lets you choose one path out of 'status' like selection.
+  After choosing the path, it presents diff between the index
+  and the working tree file and asks you if you want to stage
+  the change of each hunk.  You can say:
+
+       y - add the change from that hunk to index
+       n - do not add the change from that hunk to index
+       a - add the change from that hunk and all the rest to index
+       d - do not the change from that hunk nor any of the rest to index
+       j - do not decide on this hunk now, and view the next
+           undecided hunk
+       J - do not decide on this hunk now, and view the next hunk
+       k - do not decide on this hunk now, and view the previous
+           undecided hunk
+       K - do not decide on this hunk now, and view the previous hunk
++
+After deciding the fate for all hunks, if there is any hunk
+that was chosen, the index is updated with the selected hunks.
+
+diff::
+
+  This lets you review what will be committed (i.e. between
+  HEAD and index).
+
+
 See Also
 --------
 gitlink:git-status[1]
index 8827dee59ccee9365b19113e28034ef822776026..475047f100ef1a2a9cf73557eba3b300e74cac9d 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -170,6 +170,7 @@ SCRIPT_SH = \
        git-lost-found.sh git-quiltimport.sh
 
 SCRIPT_PERL = \
+       git-add--interactive.perl \
        git-archimport.perl git-cvsimport.perl git-relink.perl \
        git-cvsserver.perl \
        git-svnimport.perl git-cvsexportcommit.perl \
index f306f82b16c3c3c76416610cd4cabf0a307924ed..17641b433d5249a88a396fa4d68415f1d1dcd7ca 100644 (file)
@@ -6,10 +6,11 @@
 #include "cache.h"
 #include "builtin.h"
 #include "dir.h"
+#include "exec_cmd.h"
 #include "cache-tree.h"
 
 static const char builtin_add_usage[] =
-"git-add [-n] [-v] <filepattern>...";
+"git-add [-n] [-v] [--interactive] [--] <filepattern>...";
 
 static void prune_directory(struct dir_struct *dir, const char **pathspec, int prefix)
 {
@@ -87,6 +88,20 @@ int cmd_add(int argc, const char **argv, const char *prefix)
        int verbose = 0, show_only = 0;
        const char **pathspec;
        struct dir_struct dir;
+       int add_interactive = 0;
+
+       for (i = 1; i < argc; i++) {
+               if (!strcmp("--interactive", argv[i]))
+                       add_interactive++;
+       }
+       if (add_interactive) {
+               const char *args[] = { "add--interactive", NULL };
+
+               if (add_interactive != 1 || argc != 2)
+                       die("add --interactive does not take any parameters");
+               execv_git_cmd(args);
+               exit(1);
+       }
 
        git_config(git_default_config);
 
diff --git a/git-add--interactive.perl b/git-add--interactive.perl
new file mode 100755 (executable)
index 0000000..0057f86
--- /dev/null
@@ -0,0 +1,804 @@
+#!/usr/bin/perl -w
+
+
+use strict;
+
+sub run_cmd_pipe {
+       my $fh = undef;
+       open($fh, '-|', @_) or die;
+       return <$fh>;
+}
+
+my ($GIT_DIR) = run_cmd_pipe(qw(git rev-parse --git-dir));
+
+if (!defined $GIT_DIR) {
+       exit(1); # rev-parse would have already said "not a git repo"
+}
+chomp($GIT_DIR);
+
+sub refresh {
+       my $fh;
+       open $fh, '-|', qw(git update-index --refresh)
+           or die;
+       while (<$fh>) {
+               ;# ignore 'needs update'
+       }
+       close $fh;
+}
+
+sub list_untracked {
+       map {
+               chomp $_;
+               $_;
+       }
+       run_cmd_pipe(qw(git ls-files --others
+                       --exclude-per-directory=.gitignore),
+                    "--exclude-from=$GIT_DIR/info/exclude",
+                    '--', @_);
+}
+
+my $status_fmt = '%12s %12s %s';
+my $status_head = sprintf($status_fmt, 'staged', 'unstaged', 'path');
+
+# Returns list of hashes, contents of each of which are:
+# PRINT:       print message
+# VALUE:       pathname
+# BINARY:      is a binary path
+# INDEX:       is index different from HEAD?
+# FILE:                is file different from index?
+# INDEX_ADDDEL:        is it add/delete between HEAD and index?
+# FILE_ADDDEL: is it add/delete between index and file?
+
+sub list_modified {
+       my ($only) = @_;
+       my (%data, @return);
+       my ($add, $del, $adddel, $file);
+
+       for (run_cmd_pipe(qw(git diff-index --cached
+                            --numstat --summary HEAD))) {
+               if (($add, $del, $file) =
+                   /^([-\d]+)  ([-\d]+)        (.*)/) {
+                       my ($change, $bin);
+                       if ($add eq '-' && $del eq '-') {
+                               $change = 'binary';
+                               $bin = 1;
+                       }
+                       else {
+                               $change = "+$add/-$del";
+                       }
+                       $data{$file} = {
+                               INDEX => $change,
+                               BINARY => $bin,
+                               FILE => 'nothing',
+                       }
+               }
+               elsif (($adddel, $file) =
+                      /^ (create|delete) mode [0-7]+ (.*)$/) {
+                       $data{$file}{INDEX_ADDDEL} = $adddel;
+               }
+       }
+
+       for (run_cmd_pipe(qw(git diff-files --numstat --summary))) {
+               if (($add, $del, $file) =
+                   /^([-\d]+)  ([-\d]+)        (.*)/) {
+                       if (!exists $data{$file}) {
+                               $data{$file} = +{
+                                       INDEX => 'unchanged',
+                                       BINARY => 0,
+                               };
+                       }
+                       my ($change, $bin);
+                       if ($add eq '-' && $del eq '-') {
+                               $change = 'binary';
+                               $bin = 1;
+                       }
+                       else {
+                               $change = "+$add/-$del";
+                       }
+                       $data{$file}{FILE} = $change;
+                       if ($bin) {
+                               $data{$file}{BINARY} = 1;
+                       }
+               }
+               elsif (($adddel, $file) =
+                      /^ (create|delete) mode [0-7]+ (.*)$/) {
+                       $data{$file}{FILE_ADDDEL} = $adddel;
+               }
+       }
+
+       for (sort keys %data) {
+               my $it = $data{$_};
+
+               if ($only) {
+                       if ($only eq 'index-only') {
+                               next if ($it->{INDEX} eq 'unchanged');
+                       }
+                       if ($only eq 'file-only') {
+                               next if ($it->{FILE} eq 'nothing');
+                       }
+               }
+               push @return, +{
+                       VALUE => $_,
+                       PRINT => (sprintf $status_fmt,
+                                 $it->{INDEX}, $it->{FILE}, $_),
+                       %$it,
+               };
+       }
+       return @return;
+}
+
+sub find_unique {
+       my ($string, @stuff) = @_;
+       my $found = undef;
+       for (my $i = 0; $i < @stuff; $i++) {
+               my $it = $stuff[$i];
+               my $hit = undef;
+               if (ref $it) {
+                       if ((ref $it) eq 'ARRAY') {
+                               $it = $it->[0];
+                       }
+                       else {
+                               $it = $it->{VALUE};
+                       }
+               }
+               eval {
+                       if ($it =~ /^$string/) {
+                               $hit = 1;
+                       };
+               };
+               if (defined $hit && defined $found) {
+                       return undef;
+               }
+               if ($hit) {
+                       $found = $i + 1;
+               }
+       }
+       return $found;
+}
+
+sub list_and_choose {
+       my ($opts, @stuff) = @_;
+       my (@chosen, @return);
+       my $i;
+
+      TOPLOOP:
+       while (1) {
+               my $last_lf = 0;
+
+               if ($opts->{HEADER}) {
+                       if (!$opts->{LIST_FLAT}) {
+                               print "     ";
+                       }
+                       print "$opts->{HEADER}\n";
+               }
+               for ($i = 0; $i < @stuff; $i++) {
+                       my $chosen = $chosen[$i] ? '*' : ' ';
+                       my $print = $stuff[$i];
+                       if (ref $print) {
+                               if ((ref $print) eq 'ARRAY') {
+                                       $print = $print->[0];
+                               }
+                               else {
+                                       $print = $print->{PRINT};
+                               }
+                       }
+                       printf("%s%2d: %s", $chosen, $i+1, $print);
+                       if (($opts->{LIST_FLAT}) &&
+                           (($i + 1) % ($opts->{LIST_FLAT}))) {
+                               print "\t";
+                               $last_lf = 0;
+                       }
+                       else {
+                               print "\n";
+                               $last_lf = 1;
+                       }
+               }
+               if (!$last_lf) {
+                       print "\n";
+               }
+
+               return if ($opts->{LIST_ONLY});
+
+               print $opts->{PROMPT};
+               if ($opts->{SINGLETON}) {
+                       print "> ";
+               }
+               else {
+                       print ">> ";
+               }
+               my $line = <STDIN>;
+               last if (!$line);
+               chomp $line;
+               my $donesomething = 0;
+               for my $choice (split(/[\s,]+/, $line)) {
+                       my $choose = 1;
+                       my ($bottom, $top);
+
+                       # Input that begins with '-'; unchoose
+                       if ($choice =~ s/^-//) {
+                               $choose = 0;
+                       }
+                       # A range can be specified like 5-7
+                       if ($choice =~ /^(\d+)-(\d+)$/) {
+                               ($bottom, $top) = ($1, $2);
+                       }
+                       elsif ($choice =~ /^\d+$/) {
+                               $bottom = $top = $choice;
+                       }
+                       elsif ($choice eq '*') {
+                               $bottom = 1;
+                               $top = 1 + @stuff;
+                       }
+                       else {
+                               $bottom = $top = find_unique($choice, @stuff);
+                               if (!defined $bottom) {
+                                       print "Huh ($choice)?\n";
+                                       next TOPLOOP;
+                               }
+                       }
+                       if ($opts->{SINGLETON} && $bottom != $top) {
+                               print "Huh ($choice)?\n";
+                               next TOPLOOP;
+                       }
+                       for ($i = $bottom-1; $i <= $top-1; $i++) {
+                               next if (@stuff <= $i);
+                               $chosen[$i] = $choose;
+                               $donesomething++;
+                       }
+               }
+               last if (!$donesomething || $opts->{IMMEDIATE});
+       }
+       for ($i = 0; $i < @stuff; $i++) {
+               if ($chosen[$i]) {
+                       push @return, $stuff[$i];
+               }
+       }
+       return @return;
+}
+
+sub status_cmd {
+       list_and_choose({ LIST_ONLY => 1, HEADER => $status_head },
+                       list_modified());
+       print "\n";
+}
+
+sub say_n_paths {
+       my $did = shift @_;
+       my $cnt = scalar @_;
+       print "$did ";
+       if (1 < $cnt) {
+               print "$cnt paths\n";
+       }
+       else {
+               print "one path\n";
+       }
+}
+
+sub update_cmd {
+       my @mods = list_modified('file-only');
+       return if (!@mods);
+
+       my @update = list_and_choose({ PROMPT => 'Update',
+                                      HEADER => $status_head, },
+                                    @mods);
+       if (@update) {
+               system(qw(git update-index --add --),
+                      map { $_->{VALUE} } @update);
+               say_n_paths('updated', @update);
+       }
+       print "\n";
+}
+
+sub revert_cmd {
+       my @update = list_and_choose({ PROMPT => 'Revert',
+                                      HEADER => $status_head, },
+                                    list_modified());
+       if (@update) {
+               my @lines = run_cmd_pipe(qw(git ls-tree HEAD --),
+                                        map { $_->{VALUE} } @update);
+               my $fh;
+               open $fh, '|-', qw(git update-index --index-info)
+                   or die;
+               for (@lines) {
+                       print $fh $_;
+               }
+               close($fh);
+               for (@update) {
+                       if ($_->{INDEX_ADDDEL} &&
+                           $_->{INDEX_ADDDEL} eq 'create') {
+                               system(qw(git update-index --force-remove --),
+                                      $_->{VALUE});
+                               print "note: $_->{VALUE} is untracked now.\n";
+                       }
+               }
+               refresh();
+               say_n_paths('reverted', @update);
+       }
+       print "\n";
+}
+
+sub add_untracked_cmd {
+       my @add = list_and_choose({ PROMPT => 'Add untracked' },
+                                 list_untracked());
+       if (@add) {
+               system(qw(git update-index --add --), @add);
+               say_n_paths('added', @add);
+       }
+       print "\n";
+}
+
+sub parse_diff {
+       my ($path) = @_;
+       my @diff = run_cmd_pipe(qw(git diff-files -p --), $path);
+       my (@hunk) = { TEXT => [] };
+
+       for (@diff) {
+               if (/^@@ /) {
+                       push @hunk, { TEXT => [] };
+               }
+               push @{$hunk[-1]{TEXT}}, $_;
+       }
+       return @hunk;
+}
+
+sub hunk_splittable {
+       my ($text) = @_;
+
+       my @s = split_hunk($text);
+       return (1 < @s);
+}
+
+sub parse_hunk_header {
+       my ($line) = @_;
+       my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) =
+           $line =~ /^@@ -(\d+)(?:,(\d+)) \+(\d+)(?:,(\d+)) @@/;
+       return ($o_ofs, $o_cnt, $n_ofs, $n_cnt);
+}
+
+sub split_hunk {
+       my ($text) = @_;
+       my @split = ();
+
+       # If there are context lines in the middle of a hunk,
+       # it can be split, but we would need to take care of
+       # overlaps later.
+
+       my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) = parse_hunk_header($text->[0]);
+       my $hunk_start = 1;
+       my $next_hunk_start;
+
+      OUTER:
+       while (1) {
+               my $next_hunk_start = undef;
+               my $i = $hunk_start - 1;
+               my $this = +{
+                       TEXT => [],
+                       OLD => $o_ofs,
+                       NEW => $n_ofs,
+                       OCNT => 0,
+                       NCNT => 0,
+                       ADDDEL => 0,
+                       POSTCTX => 0,
+               };
+
+               while (++$i < @$text) {
+                       my $line = $text->[$i];
+                       if ($line =~ /^ /) {
+                               if ($this->{ADDDEL} &&
+                                   !defined $next_hunk_start) {
+                                       # We have seen leading context and
+                                       # adds/dels and then here is another
+                                       # context, which is trailing for this
+                                       # split hunk and leading for the next
+                                       # one.
+                                       $next_hunk_start = $i;
+                               }
+                               push @{$this->{TEXT}}, $line;
+                               $this->{OCNT}++;
+                               $this->{NCNT}++;
+                               if (defined $next_hunk_start) {
+                                       $this->{POSTCTX}++;
+                               }
+                               next;
+                       }
+
+                       # add/del
+                       if (defined $next_hunk_start) {
+                               # We are done with the current hunk and
+                               # this is the first real change for the
+                               # next split one.
+                               $hunk_start = $next_hunk_start;
+                               $o_ofs = $this->{OLD} + $this->{OCNT};
+                               $n_ofs = $this->{NEW} + $this->{NCNT};
+                               $o_ofs -= $this->{POSTCTX};
+                               $n_ofs -= $this->{POSTCTX};
+                               push @split, $this;
+                               redo OUTER;
+                       }
+                       push @{$this->{TEXT}}, $line;
+                       $this->{ADDDEL}++;
+                       if ($line =~ /^-/) {
+                               $this->{OCNT}++;
+                       }
+                       else {
+                               $this->{NCNT}++;
+                       }
+               }
+
+               push @split, $this;
+               last;
+       }
+
+       for my $hunk (@split) {
+               $o_ofs = $hunk->{OLD};
+               $n_ofs = $hunk->{NEW};
+               $o_cnt = $hunk->{OCNT};
+               $n_cnt = $hunk->{NCNT};
+
+               my $head = ("@@ -$o_ofs" .
+                           (($o_cnt != 1) ? ",$o_cnt" : '') .
+                           " +$n_ofs" .
+                           (($n_cnt != 1) ? ",$n_cnt" : '') .
+                           " @@\n");
+               unshift @{$hunk->{TEXT}}, $head;
+       }
+       return map { $_->{TEXT} } @split;
+}
+
+sub find_last_o_ctx {
+       my ($it) = @_;
+       my $text = $it->{TEXT};
+       my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) = parse_hunk_header($text->[0]);
+       my $i = @{$text};
+       my $last_o_ctx = $o_ofs + $o_cnt;
+       while (0 < --$i) {
+               my $line = $text->[$i];
+               if ($line =~ /^ /) {
+                       $last_o_ctx--;
+                       next;
+               }
+               last;
+       }
+       return $last_o_ctx;
+}
+
+sub merge_hunk {
+       my ($prev, $this) = @_;
+       my ($o0_ofs, $o0_cnt, $n0_ofs, $n0_cnt) =
+           parse_hunk_header($prev->{TEXT}[0]);
+       my ($o1_ofs, $o1_cnt, $n1_ofs, $n1_cnt) =
+           parse_hunk_header($this->{TEXT}[0]);
+
+       my (@line, $i, $ofs, $o_cnt, $n_cnt);
+       $ofs = $o0_ofs;
+       $o_cnt = $n_cnt = 0;
+       for ($i = 1; $i < @{$prev->{TEXT}}; $i++) {
+               my $line = $prev->{TEXT}[$i];
+               if ($line =~ /^\+/) {
+                       $n_cnt++;
+                       push @line, $line;
+                       next;
+               }
+
+               last if ($o1_ofs <= $ofs);
+
+               $o_cnt++;
+               $ofs++;
+               if ($line =~ /^ /) {
+                       $n_cnt++;
+               }
+               push @line, $line;
+       }
+
+       for ($i = 1; $i < @{$this->{TEXT}}; $i++) {
+               my $line = $this->{TEXT}[$i];
+               if ($line =~ /^\+/) {
+                       $n_cnt++;
+                       push @line, $line;
+                       next;
+               }
+               $ofs++;
+               $o_cnt++;
+               if ($line =~ /^ /) {
+                       $n_cnt++;
+               }
+               push @line, $line;
+       }
+       my $head = ("@@ -$o0_ofs" .
+                   (($o_cnt != 1) ? ",$o_cnt" : '') .
+                   " +$n0_ofs" .
+                   (($n_cnt != 1) ? ",$n_cnt" : '') .
+                   " @@\n");
+       @{$prev->{TEXT}} = ($head, @line);
+}
+
+sub coalesce_overlapping_hunks {
+       my (@in) = @_;
+       my @out = ();
+
+       my ($last_o_ctx);
+
+       for (grep { $_->{USE} } @in) {
+               my $text = $_->{TEXT};
+               my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) =
+                   parse_hunk_header($text->[0]);
+               if (defined $last_o_ctx &&
+                   $o_ofs <= $last_o_ctx) {
+                       merge_hunk($out[-1], $_);
+               }
+               else {
+                       push @out, $_;
+               }
+               $last_o_ctx = find_last_o_ctx($out[-1]);
+       }
+       return @out;
+}
+
+sub help_patch_cmd {
+       print <<\EOF ;
+y - stage this hunk
+n - do not stage this hunk
+a - stage this and all the remaining hunks
+d - do not stage this hunk nor any of the remaining hunks
+j - leave this hunk undecided, see next undecided hunk
+J - leave this hunk undecided, see next hunk
+k - leave this hunk undecided, see previous undecided hunk
+K - leave this hunk undecided, see previous hunk
+s - split the current hunk into smaller hunks
+EOF
+}
+
+sub patch_update_cmd {
+       my @mods = list_modified('file-only');
+       @mods = grep { !($_->{BINARY}) } @mods;
+       return if (!@mods);
+
+       my ($it) = list_and_choose({ PROMPT => 'Patch update',
+                                    SINGLETON => 1,
+                                    IMMEDIATE => 1,
+                                    HEADER => $status_head, },
+                                  @mods);
+       return if (!$it);
+
+       my ($ix, $num);
+       my $path = $it->{VALUE};
+       my ($head, @hunk) = parse_diff($path);
+       for (@{$head->{TEXT}}) {
+               print;
+       }
+       $num = scalar @hunk;
+       $ix = 0;
+
+       while (1) {
+               my ($prev, $next, $other, $undecided, $i);
+               $other = '';
+
+               if ($num <= $ix) {
+                       $ix = 0;
+               }
+               for ($i = 0; $i < $ix; $i++) {
+                       if (!defined $hunk[$i]{USE}) {
+                               $prev = 1;
+                               $other .= '/k';
+                               last;
+                       }
+               }
+               if ($ix) {
+                       $other .= '/K';
+               }
+               for ($i = $ix + 1; $i < $num; $i++) {
+                       if (!defined $hunk[$i]{USE}) {
+                               $next = 1;
+                               $other .= '/j';
+                               last;
+                       }
+               }
+               if ($ix < $num - 1) {
+                       $other .= '/J';
+               }
+               for ($i = 0; $i < $num; $i++) {
+                       if (!defined $hunk[$i]{USE}) {
+                               $undecided = 1;
+                               last;
+                       }
+               }
+               last if (!$undecided);
+
+               if (hunk_splittable($hunk[$ix]{TEXT})) {
+                       $other .= '/s';
+               }
+               for (@{$hunk[$ix]{TEXT}}) {
+                       print;
+               }
+               print "Stage this hunk [y/n/a/d$other/?]? ";
+               my $line = <STDIN>;
+               if ($line) {
+                       if ($line =~ /^y/i) {
+                               $hunk[$ix]{USE} = 1;
+                       }
+                       elsif ($line =~ /^n/i) {
+                               $hunk[$ix]{USE} = 0;
+                       }
+                       elsif ($line =~ /^a/i) {
+                               while ($ix < $num) {
+                                       if (!defined $hunk[$ix]{USE}) {
+                                               $hunk[$ix]{USE} = 1;
+                                       }
+                                       $ix++;
+                               }
+                               next;
+                       }
+                       elsif ($line =~ /^d/i) {
+                               while ($ix < $num) {
+                                       if (!defined $hunk[$ix]{USE}) {
+                                               $hunk[$ix]{USE} = 0;
+                                       }
+                                       $ix++;
+                               }
+                               next;
+                       }
+                       elsif ($other =~ /K/ && $line =~ /^K/) {
+                               $ix--;
+                               next;
+                       }
+                       elsif ($other =~ /J/ && $line =~ /^J/) {
+                               $ix++;
+                               next;
+                       }
+                       elsif ($other =~ /k/ && $line =~ /^k/) {
+                               while (1) {
+                                       $ix--;
+                                       last if (!$ix ||
+                                                !defined $hunk[$ix]{USE});
+                               }
+                               next;
+                       }
+                       elsif ($other =~ /j/ && $line =~ /^j/) {
+                               while (1) {
+                                       $ix++;
+                                       last if ($ix >= $num ||
+                                                !defined $hunk[$ix]{USE});
+                               }
+                               next;
+                       }
+                       elsif ($other =~ /s/ && $line =~ /^s/) {
+                               my @split = split_hunk($hunk[$ix]{TEXT});
+                               if (1 < @split) {
+                                       print "Split into ",
+                                       scalar(@split), " hunks.\n";
+                               }
+                               splice(@hunk, $ix, 1,
+                                      map { +{ TEXT => $_, USE => undef } }
+                                      @split);
+                               $num = scalar @hunk;
+                               next;
+                       }
+                       else {
+                               help_patch_cmd($other);
+                               next;
+                       }
+                       # soft increment
+                       while (1) {
+                               $ix++;
+                               last if ($ix >= $num ||
+                                        !defined $hunk[$ix]{USE});
+                       }
+               }
+       }
+
+       @hunk = coalesce_overlapping_hunks(@hunk);
+
+       my ($o_lofs, $n_lofs) = (0, 0);
+       my @result = ();
+       for (@hunk) {
+               my $text = $_->{TEXT};
+               my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) =
+                   parse_hunk_header($text->[0]);
+
+               if (!$_->{USE}) {
+                       if (!defined $o_cnt) { $o_cnt = 1; }
+                       if (!defined $n_cnt) { $n_cnt = 1; }
+
+                       # We would have added ($n_cnt - $o_cnt) lines
+                       # to the postimage if we were to use this hunk,
+                       # but we didn't.  So the line number that the next
+                       # hunk starts at would be shifted by that much.
+                       $n_lofs -= ($n_cnt - $o_cnt);
+                       next;
+               }
+               else {
+                       if ($n_lofs) {
+                               $n_ofs += $n_lofs;
+                               $text->[0] = ("@@ -$o_ofs" .
+                                             ((defined $o_cnt)
+                                              ? ",$o_cnt" : '') .
+                                             " +$n_ofs" .
+                                             ((defined $n_cnt)
+                                              ? ",$n_cnt" : '') .
+                                             " @@\n");
+                       }
+                       for (@$text) {
+                               push @result, $_;
+                       }
+               }
+       }
+
+       if (@result) {
+               my $fh;
+
+               open $fh, '|-', qw(git apply --cached);
+               for (@{$head->{TEXT}}, @result) {
+                       print $fh $_;
+               }
+               if (!close $fh) {
+                       for (@{$head->{TEXT}}, @result) {
+                               print STDERR $_;
+                       }
+               }
+               refresh();
+       }
+
+       print "\n";
+}
+
+sub diff_cmd {
+       my @mods = list_modified('index-only');
+       @mods = grep { !($_->{BINARY}) } @mods;
+       return if (!@mods);
+       my (@them) = list_and_choose({ PROMPT => 'Review diff',
+                                    IMMEDIATE => 1,
+                                    HEADER => $status_head, },
+                                  @mods);
+       return if (!@them);
+       system(qw(git diff-index -p --cached HEAD --),
+              map { $_->{VALUE} } @them);
+}
+
+sub quit_cmd {
+       print "Bye.\n";
+       exit(0);
+}
+
+sub help_cmd {
+       print <<\EOF ;
+status        - show paths with changes
+update        - add working tree state to the staged set of changes
+revert        - revert staged set of changes back to the HEAD version
+patch         - pick hunks and update selectively
+diff         - view diff between HEAD and index
+add untracked - add contents of untracked files to the staged set of changes
+EOF
+}
+
+sub main_loop {
+       my @cmd = ([ 'status', \&status_cmd, ],
+                  [ 'update', \&update_cmd, ],
+                  [ 'revert', \&revert_cmd, ],
+                  [ 'add untracked', \&add_untracked_cmd, ],
+                  [ 'patch', \&patch_update_cmd, ],
+                  [ 'diff', \&diff_cmd, ],
+                  [ 'quit', \&quit_cmd, ],
+                  [ 'help', \&help_cmd, ],
+       );
+       while (1) {
+               my ($it) = list_and_choose({ PROMPT => 'What now',
+                                            SINGLETON => 1,
+                                            LIST_FLAT => 4,
+                                            HEADER => '*** Commands ***',
+                                            IMMEDIATE => 1 }, @cmd);
+               if ($it) {
+                       eval {
+                               $it->[1]->();
+                       };
+                       if ($@) {
+                               print "$@";
+                       }
+               }
+       }
+}
+
+my @z;
+
+refresh();
+status_cmd();
+main_loop();