Implement 'git checkout --patch'
authorThomas Rast <trast@student.ethz.ch>
Sat, 15 Aug 2009 11:48:30 +0000 (13:48 +0200)
committerJunio C Hamano <gitster@pobox.com>
Sat, 15 Aug 2009 22:18:05 +0000 (15:18 -0700)
This introduces a --patch mode for git-checkout.  In the index usage

  git checkout --patch -- [files...]

it lets the user discard edits from the <files> at the granularity of
hunks (by selecting hunks from 'git diff' and then reverse applying
them to the worktree).

We also accept a revision argument.  In the case

  git checkout --patch HEAD -- [files...]

we offer hunks from the difference between HEAD and the worktree, and
reverse applies them to both index and worktree, allowing you to
discard staged changes completely.  In the non-HEAD usage

  git checkout --patch <revision> -- [files...]

it offers hunks from the difference between the worktree and
<revision>.  The chosen hunks are then applied to both index and
worktree.

The application to worktree and index is done "atomically" in the
sense that we first check if the patch applies to the index (it should
always apply to the worktree).  If it does not, we give the user a
choice to either abort or apply to the worktree anyway.

Signed-off-by: Thomas Rast <trast@student.ethz.ch>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
Documentation/git-checkout.txt
builtin-checkout.c
git-add--interactive.perl
t/t2015-checkout-patch.sh [new file with mode: 0755]

index ad4b31e89218e857001fce69f32acbe85fc7539c..26a5447fbfd2c6ec2c23d6328314024712601c92 100644 (file)
@@ -11,6 +11,7 @@ SYNOPSIS
 'git checkout' [-q] [-f] [-m] [<branch>]
 'git checkout' [-q] [-f] [-m] [-b <new_branch>] [<start_point>]
 'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] [<tree-ish>] [--] <paths>...
+'git checkout' --patch [<tree-ish>] [--] [<paths>...]
 
 DESCRIPTION
 -----------
@@ -25,7 +26,7 @@ use the --track or --no-track options, which will be passed to `git
 branch`.  As a convenience, --track without `-b` implies branch
 creation; see the description of --track below.
 
-When <paths> are given, this command does *not* switch
+When <paths> or --patch are given, this command does *not* switch
 branches.  It updates the named paths in the working tree from
 the index file, or from a named <tree-ish> (most often a commit).  In
 this case, the `-b` and `--track` options are meaningless and giving
@@ -113,6 +114,16 @@ the conflicted merge in the specified paths.
        "merge" (default) and "diff3" (in addition to what is shown by
        "merge" style, shows the original contents).
 
+-p::
+--patch::
+       Interactively select hunks in the difference between the
+       <tree-ish> (or the index, if unspecified) and the working
+       tree.  The chosen hunks are then applied in reverse to the
+       working tree (and if a <tree-ish> was specified, the index).
++
+This means that you can use `git checkout -p` to selectively discard
+edits from your current working tree.
+
 <branch>::
        Branch to checkout; if it refers to a branch (i.e., a name that,
        when prepended with "refs/heads/", is a valid ref), then that
index 8a9a474218c128b7878647045ba471df9902636a..8b942bafeb7e5a4b1f9be912109184be002f14f1 100644 (file)
@@ -572,6 +572,13 @@ static int git_checkout_config(const char *var, const char *value, void *cb)
        return git_xmerge_config(var, value, cb);
 }
 
+static int interactive_checkout(const char *revision, const char **pathspec,
+                               struct checkout_opts *opts)
+{
+       return run_add_interactive(revision, "--patch=checkout", pathspec);
+}
+
+
 int cmd_checkout(int argc, const char **argv, const char *prefix)
 {
        struct checkout_opts opts;
@@ -580,6 +587,7 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
        struct branch_info new;
        struct tree *source_tree = NULL;
        char *conflict_style = NULL;
+       int patch_mode = 0;
        struct option options[] = {
                OPT__QUIET(&opts.quiet),
                OPT_STRING('b', NULL, &opts.new_branch, "new branch", "branch"),
@@ -594,6 +602,7 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
                OPT_BOOLEAN('m', "merge", &opts.merge, "merge"),
                OPT_STRING(0, "conflict", &conflict_style, "style",
                           "conflict style (merge or diff3)"),
+               OPT_BOOLEAN('p', "patch", &patch_mode, "select hunks interactively"),
                OPT_END(),
        };
        int has_dash_dash;
@@ -608,6 +617,10 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
        argc = parse_options(argc, argv, prefix, options, checkout_usage,
                             PARSE_OPT_KEEP_DASHDASH);
 
+       if (patch_mode && (opts.track > 0 || opts.new_branch
+                          || opts.new_branch_log || opts.merge || opts.force))
+               die ("--patch is incompatible with all other options");
+
        /* --track without -b should DWIM */
        if (0 < opts.track && !opts.new_branch) {
                const char *argv0 = argv[0];
@@ -714,6 +727,9 @@ no_reference:
                if (!pathspec)
                        die("invalid path specification");
 
+               if (patch_mode)
+                       return interactive_checkout(new.name, pathspec, &opts);
+
                /* Checkout paths */
                if (opts.new_branch) {
                        if (argc == 1) {
@@ -729,6 +745,9 @@ no_reference:
                return checkout_paths(source_tree, pathspec, &opts);
        }
 
+       if (patch_mode)
+               return interactive_checkout(new.name, NULL, &opts);
+
        if (opts.new_branch) {
                struct strbuf buf = STRBUF_INIT;
                if (strbuf_check_branch_ref(&buf, opts.new_branch))
index d14f48c8379cb287e2e1bbe9c8fb7946e6b160fe..21746d5f2834833a49379b4f23160d65fe2501ba 100755 (executable)
@@ -75,6 +75,7 @@ my $patch_mode;
 my $patch_mode_revision;
 
 sub apply_patch;
+sub apply_patch_for_checkout_commit;
 
 my %patch_modes = (
        'stage' => {
@@ -104,6 +105,33 @@ my %patch_modes = (
                PARTICIPLE => 'applying',
                FILTER => 'index-only',
        },
+       'checkout_index' => {
+               DIFF => 'diff-files -p',
+               APPLY => sub { apply_patch 'apply -R', @_; },
+               APPLY_CHECK => 'apply -R',
+               VERB => 'Discard',
+               TARGET => ' from worktree',
+               PARTICIPLE => 'discarding',
+               FILTER => 'file-only',
+       },
+       'checkout_head' => {
+               DIFF => 'diff-index -p',
+               APPLY => sub { apply_patch_for_checkout_commit '-R', @_ },
+               APPLY_CHECK => 'apply -R',
+               VERB => 'Discard',
+               TARGET => ' from index and worktree',
+               PARTICIPLE => 'discarding',
+               FILTER => undef,
+       },
+       'checkout_nothead' => {
+               DIFF => 'diff-index -R -p',
+               APPLY => sub { apply_patch_for_checkout_commit '', @_ },
+               APPLY_CHECK => 'apply',
+               VERB => 'Apply',
+               TARGET => ' to index and worktree',
+               PARTICIPLE => 'applying',
+               FILTER => undef,
+       },
 );
 
 my %patch_mode_flavour = %{$patch_modes{stage}};
@@ -1069,6 +1097,29 @@ sub apply_patch {
        return $ret;
 }
 
+sub apply_patch_for_checkout_commit {
+       my $reverse = shift;
+       my $applies_index = run_git_apply 'apply '.$reverse.' --cached --recount --check', @_;
+       my $applies_worktree = run_git_apply 'apply '.$reverse.' --recount --check', @_;
+
+       if ($applies_worktree && $applies_index) {
+               run_git_apply 'apply '.$reverse.' --cached --recount', @_;
+               run_git_apply 'apply '.$reverse.' --recount', @_;
+               return 1;
+       } elsif (!$applies_index) {
+               print colored $error_color, "The selected hunks do not apply to the index!\n";
+               if (prompt_yesno "Apply them to the worktree anyway? ") {
+                       return run_git_apply 'apply '.$reverse.' --recount', @_;
+               } else {
+                       print colored $error_color, "Nothing was applied.\n";
+                       return 0;
+               }
+       } else {
+               print STDERR @_;
+               return 0;
+       }
+}
+
 sub patch_update_cmd {
        my @all_mods = list_modified($patch_mode_flavour{FILTER});
        my @mods = grep { !($_->{BINARY}) } @all_mods;
@@ -1432,6 +1483,16 @@ sub process_args {
                                                       'reset_head' : 'reset_nothead');
                                        $arg = shift @ARGV or die "missing --";
                                }
+                       } elsif ($1 eq 'checkout') {
+                               $arg = shift @ARGV or die "missing --";
+                               if ($arg eq '--') {
+                                       $patch_mode = 'checkout_index';
+                               } else {
+                                       $patch_mode_revision = $arg;
+                                       $patch_mode = ($arg eq 'HEAD' ?
+                                                      'checkout_head' : 'checkout_nothead');
+                                       $arg = shift @ARGV or die "missing --";
+                               }
                        } elsif ($1 eq 'stage') {
                                $patch_mode = 'stage';
                                $arg = shift @ARGV or die "missing --";
diff --git a/t/t2015-checkout-patch.sh b/t/t2015-checkout-patch.sh
new file mode 100755 (executable)
index 0000000..4d1c2e9
--- /dev/null
@@ -0,0 +1,107 @@
+#!/bin/sh
+
+test_description='git checkout --patch'
+
+. ./lib-patch-mode.sh
+
+test_expect_success 'setup' '
+       mkdir dir &&
+       echo parent > dir/foo &&
+       echo dummy > bar &&
+       git add bar dir/foo &&
+       git commit -m initial &&
+       test_tick &&
+       test_commit second dir/foo head &&
+       set_and_save_state bar bar_work bar_index &&
+       save_head
+'
+
+# note: bar sorts before dir/foo, so the first 'n' is always to skip 'bar'
+
+test_expect_success 'saying "n" does nothing' '
+       set_and_save_state dir/foo work head &&
+       (echo n; echo n) | git checkout -p &&
+       verify_saved_state bar &&
+       verify_saved_state dir/foo
+'
+
+test_expect_success 'git checkout -p' '
+       (echo n; echo y) | git checkout -p &&
+       verify_saved_state bar &&
+       verify_state dir/foo head head
+'
+
+test_expect_success 'git checkout -p with staged changes' '
+       set_state dir/foo work index
+       (echo n; echo y) | git checkout -p &&
+       verify_saved_state bar &&
+       verify_state dir/foo index index
+'
+
+test_expect_success 'git checkout -p HEAD with NO staged changes: abort' '
+       set_and_save_state dir/foo work head &&
+       (echo n; echo y; echo n) | git checkout -p HEAD &&
+       verify_saved_state bar &&
+       verify_saved_state dir/foo
+'
+
+test_expect_success 'git checkout -p HEAD with NO staged changes: apply' '
+       (echo n; echo y; echo y) | git checkout -p HEAD &&
+       verify_saved_state bar &&
+       verify_state dir/foo head head
+'
+
+test_expect_success 'git checkout -p HEAD with change already staged' '
+       set_state dir/foo index index
+       # the third n is to get out in case it mistakenly does not apply
+       (echo n; echo y; echo n) | git checkout -p HEAD &&
+       verify_saved_state bar &&
+       verify_state dir/foo head head
+'
+
+test_expect_success 'git checkout -p HEAD^' '
+       # the third n is to get out in case it mistakenly does not apply
+       (echo n; echo y; echo n) | git checkout -p HEAD^ &&
+       verify_saved_state bar &&
+       verify_state dir/foo parent parent
+'
+
+# The idea in the rest is that bar sorts first, so we always say 'y'
+# first and if the path limiter fails it'll apply to bar instead of
+# dir/foo.  There's always an extra 'n' to reject edits to dir/foo in
+# the failure case (and thus get out of the loop).
+
+test_expect_success 'path limiting works: dir' '
+       set_state dir/foo work head &&
+       (echo y; echo n) | git checkout -p dir &&
+       verify_saved_state bar &&
+       verify_state dir/foo head head
+'
+
+test_expect_success 'path limiting works: -- dir' '
+       set_state dir/foo work head &&
+       (echo y; echo n) | git checkout -p -- dir &&
+       verify_saved_state bar &&
+       verify_state dir/foo head head
+'
+
+test_expect_success 'path limiting works: HEAD^ -- dir' '
+       # the third n is to get out in case it mistakenly does not apply
+       (echo y; echo n; echo n) | git checkout -p HEAD^ -- dir &&
+       verify_saved_state bar &&
+       verify_state dir/foo parent parent
+'
+
+test_expect_success 'path limiting works: foo inside dir' '
+       set_state dir/foo work head &&
+       # the third n is to get out in case it mistakenly does not apply
+       (echo y; echo n; echo n) | (cd dir && git checkout -p foo) &&
+       verify_saved_state bar &&
+       verify_state dir/foo head head
+'
+
+test_expect_success 'none of this moved HEAD' '
+       verify_saved_head
+'
+
+test_done