git-add--interactive.perlon commit git-add --interactive (5cde71d)
   1#!/usr/bin/perl -w
   2
   3use strict;
   4
   5sub run_cmd_pipe {
   6        my $fh = undef;
   7        open($fh, '-|', @_) or die;
   8        return <$fh>;
   9}
  10
  11my ($GIT_DIR) = run_cmd_pipe(qw(git rev-parse --git-dir));
  12
  13if (!defined $GIT_DIR) {
  14        exit(1); # rev-parse would have already said "not a git repo"
  15}
  16chomp($GIT_DIR);
  17
  18sub refresh {
  19        my $fh;
  20        open $fh, '-|', qw(git update-index --refresh)
  21            or die;
  22        while (<$fh>) {
  23                ;# ignore 'needs update'
  24        }
  25        close $fh;
  26}
  27
  28sub list_untracked {
  29        map {
  30                chomp $_;
  31                $_;
  32        }
  33        run_cmd_pipe(qw(git ls-files --others
  34                        --exclude-per-directory=.gitignore),
  35                     "--exclude-from=$GIT_DIR/info/exclude",
  36                     '--', @_);
  37}
  38
  39my $status_fmt = '%12s %12s %s';
  40my $status_head = sprintf($status_fmt, 'staged', 'unstaged', 'path');
  41
  42# Returns list of hashes, contents of each of which are:
  43# PRINT:        print message
  44# VALUE:        pathname
  45# BINARY:       is a binary path
  46# INDEX:        is index different from HEAD?
  47# FILE:         is file different from index?
  48# INDEX_ADDDEL: is it add/delete between HEAD and index?
  49# FILE_ADDDEL:  is it add/delete between index and file?
  50
  51sub list_modified {
  52        my ($only) = @_;
  53        my (%data, @return);
  54        my ($add, $del, $adddel, $file);
  55
  56        for (run_cmd_pipe(qw(git diff-index --cached
  57                             --numstat --summary HEAD))) {
  58                if (($add, $del, $file) =
  59                    /^([-\d]+)  ([-\d]+)        (.*)/) {
  60                        my ($change, $bin);
  61                        if ($add eq '-' && $del eq '-') {
  62                                $change = 'binary';
  63                                $bin = 1;
  64                        }
  65                        else {
  66                                $change = "+$add/-$del";
  67                        }
  68                        $data{$file} = {
  69                                INDEX => $change,
  70                                BINARY => $bin,
  71                                FILE => 'nothing',
  72                        }
  73                }
  74                elsif (($adddel, $file) =
  75                       /^ (create|delete) mode [0-7]+ (.*)$/) {
  76                        $data{$file}{INDEX_ADDDEL} = $adddel;
  77                }
  78        }
  79
  80        for (run_cmd_pipe(qw(git diff-files --numstat --summary))) {
  81                if (($add, $del, $file) =
  82                    /^([-\d]+)  ([-\d]+)        (.*)/) {
  83                        if (!exists $data{$file}) {
  84                                $data{$file} = +{
  85                                        INDEX => 'unchanged',
  86                                        BINARY => 0,
  87                                };
  88                        }
  89                        my ($change, $bin);
  90                        if ($add eq '-' && $del eq '-') {
  91                                $change = 'binary';
  92                                $bin = 1;
  93                        }
  94                        else {
  95                                $change = "+$add/-$del";
  96                        }
  97                        $data{$file}{FILE} = $change;
  98                        if ($bin) {
  99                                $data{$file}{BINARY} = 1;
 100                        }
 101                }
 102                elsif (($adddel, $file) =
 103                       /^ (create|delete) mode [0-7]+ (.*)$/) {
 104                        $data{$file}{FILE_ADDDEL} = $adddel;
 105                }
 106        }
 107
 108        for (sort keys %data) {
 109                my $it = $data{$_};
 110
 111                if ($only) {
 112                        if ($only eq 'index-only') {
 113                                next if ($it->{INDEX} eq 'unchanged');
 114                        }
 115                        if ($only eq 'file-only') {
 116                                next if ($it->{FILE} eq 'nothing');
 117                        }
 118                }
 119                push @return, +{
 120                        VALUE => $_,
 121                        PRINT => (sprintf $status_fmt,
 122                                  $it->{INDEX}, $it->{FILE}, $_),
 123                        %$it,
 124                };
 125        }
 126        return @return;
 127}
 128
 129sub find_unique {
 130        my ($string, @stuff) = @_;
 131        my $found = undef;
 132        for (my $i = 0; $i < @stuff; $i++) {
 133                my $it = $stuff[$i];
 134                my $hit = undef;
 135                if (ref $it) {
 136                        if ((ref $it) eq 'ARRAY') {
 137                                $it = $it->[0];
 138                        }
 139                        else {
 140                                $it = $it->{VALUE};
 141                        }
 142                }
 143                eval {
 144                        if ($it =~ /^$string/) {
 145                                $hit = 1;
 146                        };
 147                };
 148                if (defined $hit && defined $found) {
 149                        return undef;
 150                }
 151                if ($hit) {
 152                        $found = $i + 1;
 153                }
 154        }
 155        return $found;
 156}
 157
 158sub list_and_choose {
 159        my ($opts, @stuff) = @_;
 160        my (@chosen, @return);
 161        my $i;
 162
 163      TOPLOOP:
 164        while (1) {
 165                my $last_lf = 0;
 166
 167                if ($opts->{HEADER}) {
 168                        if (!$opts->{LIST_FLAT}) {
 169                                print "     ";
 170                        }
 171                        print "$opts->{HEADER}\n";
 172                }
 173                for ($i = 0; $i < @stuff; $i++) {
 174                        my $chosen = $chosen[$i] ? '*' : ' ';
 175                        my $print = $stuff[$i];
 176                        if (ref $print) {
 177                                if ((ref $print) eq 'ARRAY') {
 178                                        $print = $print->[0];
 179                                }
 180                                else {
 181                                        $print = $print->{PRINT};
 182                                }
 183                        }
 184                        printf("%s%2d: %s", $chosen, $i+1, $print);
 185                        if (($opts->{LIST_FLAT}) &&
 186                            (($i + 1) % ($opts->{LIST_FLAT}))) {
 187                                print "\t";
 188                                $last_lf = 0;
 189                        }
 190                        else {
 191                                print "\n";
 192                                $last_lf = 1;
 193                        }
 194                }
 195                if (!$last_lf) {
 196                        print "\n";
 197                }
 198
 199                return if ($opts->{LIST_ONLY});
 200
 201                print $opts->{PROMPT};
 202                if ($opts->{SINGLETON}) {
 203                        print "> ";
 204                }
 205                else {
 206                        print ">> ";
 207                }
 208                my $line = <STDIN>;
 209                last if (!$line);
 210                chomp $line;
 211                my $donesomething = 0;
 212                for my $choice (split(/[\s,]+/, $line)) {
 213                        my $choose = 1;
 214                        my ($bottom, $top);
 215
 216                        # Input that begins with '-'; unchoose
 217                        if ($choice =~ s/^-//) {
 218                                $choose = 0;
 219                        }
 220                        # A range can be specified like 5-7
 221                        if ($choice =~ /^(\d+)-(\d+)$/) {
 222                                ($bottom, $top) = ($1, $2);
 223                        }
 224                        elsif ($choice =~ /^\d+$/) {
 225                                $bottom = $top = $choice;
 226                        }
 227                        elsif ($choice eq '*') {
 228                                $bottom = 1;
 229                                $top = 1 + @stuff;
 230                        }
 231                        else {
 232                                $bottom = $top = find_unique($choice, @stuff);
 233                                if (!defined $bottom) {
 234                                        print "Huh ($choice)?\n";
 235                                        next TOPLOOP;
 236                                }
 237                        }
 238                        if ($opts->{SINGLETON} && $bottom != $top) {
 239                                print "Huh ($choice)?\n";
 240                                next TOPLOOP;
 241                        }
 242                        for ($i = $bottom-1; $i <= $top-1; $i++) {
 243                                next if (@stuff <= $i);
 244                                $chosen[$i] = $choose;
 245                                $donesomething++;
 246                        }
 247                }
 248                last if (!$donesomething || $opts->{IMMEDIATE});
 249        }
 250        for ($i = 0; $i < @stuff; $i++) {
 251                if ($chosen[$i]) {
 252                        push @return, $stuff[$i];
 253                }
 254        }
 255        return @return;
 256}
 257
 258sub status_cmd {
 259        list_and_choose({ LIST_ONLY => 1, HEADER => $status_head },
 260                        list_modified());
 261        print "\n";
 262}
 263
 264sub say_n_paths {
 265        my $did = shift @_;
 266        my $cnt = scalar @_;
 267        print "$did ";
 268        if (1 < $cnt) {
 269                print "$cnt paths\n";
 270        }
 271        else {
 272                print "one path\n";
 273        }
 274}
 275
 276sub update_cmd {
 277        my @mods = list_modified('file-only');
 278        return if (!@mods);
 279
 280        my @update = list_and_choose({ PROMPT => 'Update',
 281                                       HEADER => $status_head, },
 282                                     @mods);
 283        if (@update) {
 284                system(qw(git update-index --add --),
 285                       map { $_->{VALUE} } @update);
 286                say_n_paths('updated', @update);
 287        }
 288        print "\n";
 289}
 290
 291sub revert_cmd {
 292        my @update = list_and_choose({ PROMPT => 'Revert',
 293                                       HEADER => $status_head, },
 294                                     list_modified());
 295        if (@update) {
 296                my @lines = run_cmd_pipe(qw(git ls-tree HEAD --),
 297                                         map { $_->{VALUE} } @update);
 298                my $fh;
 299                open $fh, '|-', qw(git update-index --index-info)
 300                    or die;
 301                for (@lines) {
 302                        print $fh $_;
 303                }
 304                close($fh);
 305                for (@update) {
 306                        if ($_->{INDEX_ADDDEL} &&
 307                            $_->{INDEX_ADDDEL} eq 'create') {
 308                                system(qw(git update-index --force-remove --),
 309                                       $_->{VALUE});
 310                                print "note: $_->{VALUE} is untracked now.\n";
 311                        }
 312                }
 313                refresh();
 314                say_n_paths('reverted', @update);
 315        }
 316        print "\n";
 317}
 318
 319sub add_untracked_cmd {
 320        my @add = list_and_choose({ PROMPT => 'Add untracked' },
 321                                  list_untracked());
 322        if (@add) {
 323                system(qw(git update-index --add --), @add);
 324                say_n_paths('added', @add);
 325        }
 326        print "\n";
 327}
 328
 329sub parse_diff {
 330        my ($path) = @_;
 331        my @diff = run_cmd_pipe(qw(git diff-files -p --), $path);
 332        my (@hunk) = { TEXT => [] };
 333
 334        for (@diff) {
 335                if (/^@@ /) {
 336                        push @hunk, { TEXT => [] };
 337                }
 338                push @{$hunk[-1]{TEXT}}, $_;
 339        }
 340        return @hunk;
 341}
 342
 343sub help_patch_cmd {
 344        print <<\EOF ;
 345y - stage this hunk
 346n - do not stage this hunk
 347a - stage this and all the remaining hunks
 348d - do not stage this hunk nor any of the remaining hunks
 349j - leave this hunk undecided, see next undecided hunk
 350J - leave this hunk undecided, see next hunk
 351k - leave this hunk undecided, see previous undecided hunk
 352K - leave this hunk undecided, see previous hunk
 353EOF
 354}
 355
 356sub patch_update_cmd {
 357        my @mods = list_modified('file-only');
 358        @mods = grep { !($_->{BINARY}) } @mods;
 359        return if (!@mods);
 360
 361        my ($it) = list_and_choose({ PROMPT => 'Patch update',
 362                                     SINGLETON => 1,
 363                                     IMMEDIATE => 1,
 364                                     HEADER => $status_head, },
 365                                   @mods);
 366        return if (!$it);
 367
 368        my ($ix, $num);
 369        my $path = $it->{VALUE};
 370        my ($head, @hunk) = parse_diff($path);
 371        for (@{$head->{TEXT}}) {
 372                print;
 373        }
 374        $num = scalar @hunk;
 375        $ix = 0;
 376
 377        while (1) {
 378                my ($prev, $next, $other, $undecided);
 379                $other = '';
 380
 381                if ($num <= $ix) {
 382                        $ix = 0;
 383                }
 384                for (my $i = 0; $i < $ix; $i++) {
 385                        if (!defined $hunk[$i]{USE}) {
 386                                $prev = 1;
 387                                $other .= '/k';
 388                                last;
 389                        }
 390                }
 391                if ($ix) {
 392                        $other .= '/K';
 393                }
 394                for (my $i = $ix + 1; $i < $num; $i++) {
 395                        if (!defined $hunk[$i]{USE}) {
 396                                $next = 1;
 397                                $other .= '/j';
 398                                last;
 399                        }
 400                }
 401                if ($ix < $num - 1) {
 402                        $other .= '/J';
 403                }
 404                for (my $i = 0; $i < $num; $i++) {
 405                        if (!defined $hunk[$i]{USE}) {
 406                                $undecided = 1;
 407                                last;
 408                        }
 409                }
 410                last if (!$undecided);
 411
 412                for (@{$hunk[$ix]{TEXT}}) {
 413                        print;
 414                }
 415                print "Stage this hunk [y/n/a/d$other/?]? ";
 416                my $line = <STDIN>;
 417                if ($line) {
 418                        if ($line =~ /^y/i) {
 419                                $hunk[$ix]{USE} = 1;
 420                        }
 421                        elsif ($line =~ /^n/i) {
 422                                $hunk[$ix]{USE} = 0;
 423                        }
 424                        elsif ($line =~ /^a/i) {
 425                                while ($ix < $num) {
 426                                        if (!defined $hunk[$ix]{USE}) {
 427                                                $hunk[$ix]{USE} = 1;
 428                                        }
 429                                        $ix++;
 430                                }
 431                                next;
 432                        }
 433                        elsif ($line =~ /^d/i) {
 434                                while ($ix < $num) {
 435                                        if (!defined $hunk[$ix]{USE}) {
 436                                                $hunk[$ix]{USE} = 0;
 437                                        }
 438                                        $ix++;
 439                                }
 440                                next;
 441                        }
 442                        elsif ($other =~ /K/ && $line =~ /^K/) {
 443                                $ix--;
 444                                next;
 445                        }
 446                        elsif ($other =~ /J/ && $line =~ /^J/) {
 447                                $ix++;
 448                                next;
 449                        }
 450                        elsif ($other =~ /k/ && $line =~ /^k/) {
 451                                while (1) {
 452                                        $ix--;
 453                                        last if (!$ix ||
 454                                                 !defined $hunk[$ix]{USE});
 455                                }
 456                                next;
 457                        }
 458                        elsif ($other =~ /j/ && $line =~ /^j/) {
 459                                while (1) {
 460                                        $ix++;
 461                                        last if ($ix >= $num ||
 462                                                 !defined $hunk[$ix]{USE});
 463                                }
 464                                next;
 465                        }
 466                        else {
 467                                help_patch_cmd($other);
 468                                next;
 469                        }
 470                        # soft increment
 471                        while (1) {
 472                                $ix++;
 473                                last if ($ix >= $num ||
 474                                         !defined $hunk[$ix]{USE});
 475                        }
 476                }
 477        }
 478
 479        my ($o_lno, $n_lno);
 480        my @result = ();
 481        for (@hunk) {
 482                my $text = $_->{TEXT};
 483                my ($o_ofs, $o_cnt, $n_ofs, $n_cnt) =
 484                    $text->[0] =~ /^@@ -(\d+)(?:,(\d+)) \+(\d+)(?:,(\d+)) @@/;
 485                if (!$_->{USE}) {
 486                        # Adjust offset here.
 487                        next;
 488                }
 489                else {
 490                        for (@$text) {
 491                                push @result, $_;
 492                        }
 493                }
 494        }
 495
 496        if (@result) {
 497                my $fh;
 498
 499                open $fh, '|-', qw(git apply --cached);
 500                for (@{$head->{TEXT}}, @result) {
 501                        print $fh $_;
 502                }
 503                close $fh;
 504                refresh();
 505        }
 506
 507        print "\n";
 508}
 509
 510sub diff_cmd {
 511        my @mods = list_modified('index-only');
 512        @mods = grep { !($_->{BINARY}) } @mods;
 513        return if (!@mods);
 514        my (@them) = list_and_choose({ PROMPT => 'Review diff',
 515                                     IMMEDIATE => 1,
 516                                     HEADER => $status_head, },
 517                                   @mods);
 518        return if (!@them);
 519        system(qw(git diff-index -p --cached HEAD --),
 520               map { $_->{VALUE} } @them);
 521}
 522
 523sub quit_cmd {
 524        print "Bye.\n";
 525        exit(0);
 526}
 527
 528sub help_cmd {
 529        print <<\EOF ;
 530status        - show paths with changes
 531update        - add working tree state to the staged set of changes
 532revert        - revert staged set of changes back to the HEAD version
 533patch         - pick hunks and update selectively
 534diff          - view diff between HEAD and index
 535add untracked - add contents of untracked files to the staged set of changes
 536EOF
 537}
 538
 539sub main_loop {
 540        my @cmd = ([ 'status', \&status_cmd, ],
 541                   [ 'update', \&update_cmd, ],
 542                   [ 'revert', \&revert_cmd, ],
 543                   [ 'add untracked', \&add_untracked_cmd, ],
 544                   [ 'patch', \&patch_update_cmd, ],
 545                   [ 'diff', \&diff_cmd, ],
 546                   [ 'quit', \&quit_cmd, ],
 547                   [ 'help', \&help_cmd, ],
 548        );
 549        while (1) {
 550                my ($it) = list_and_choose({ PROMPT => 'What now',
 551                                             SINGLETON => 1,
 552                                             LIST_FLAT => 4,
 553                                             HEADER => '*** Commands ***',
 554                                             IMMEDIATE => 1 }, @cmd);
 555                if ($it) {
 556                        eval {
 557                                $it->[1]->();
 558                        };
 559                        if ($@) {
 560                                print "$@";
 561                        }
 562                }
 563        }
 564}
 565
 566my @z;
 567
 568refresh();
 569status_cmd();
 570main_loop();