diff options
-rw-r--r-- | Documentation/git-checkout.txt | 13 | ||||
-rw-r--r-- | builtin-checkout.c | 19 | ||||
-rwxr-xr-x | git-add--interactive.perl | 61 | ||||
-rwxr-xr-x | t/t2015-checkout-patch.sh | 107 |
4 files changed, 199 insertions, 1 deletions
diff --git a/Documentation/git-checkout.txt b/Documentation/git-checkout.txt index ad4b31e892..26a5447fbf 100644 --- a/Documentation/git-checkout.txt +++ b/Documentation/git-checkout.txt @@ -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 diff --git a/builtin-checkout.c b/builtin-checkout.c index 8a9a474218..8b942bafeb 100644 --- a/builtin-checkout.c +++ b/builtin-checkout.c @@ -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)) diff --git a/git-add--interactive.perl b/git-add--interactive.perl index d14f48c837..21746d5f28 100755 --- a/git-add--interactive.perl +++ b/git-add--interactive.perl @@ -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 index 0000000000..4d1c2e9e09 --- /dev/null +++ b/t/t2015-checkout-patch.sh @@ -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 |