@matheusbsbs / matheus-bagliano / commits / 4f353658b9
Fork 0

Implement 'git checkout --patch'

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>

Thomas Rast committed Aug 15, 2009 at 13:48 UTC 4f353658b9c15e9188530fac5ae79d0aa1538e85
4 files changed +199 -1
Documentation/git-checkout.txt
+12 -1
@@ -11,6 +11,7 @@ SYNOPSIS
11 'git checkout' [-q] [-f] [-m] [<branch>]
12 'git checkout' [-q] [-f] [-m] [-b <new_branch>] [<start_point>]
13 'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] [<tree-ish>] [--] <paths>...
14 +'git checkout' --patch [<tree-ish>] [--] [<paths>...]
15
16 DESCRIPTION
17 -----------
@@ -25,7 +26,7 @@ use the --track or --no-track options, which will be passed to `git
26 branch`. As a convenience, --track without `-b` implies branch
27 creation; see the description of --track below.
28
28 -When <paths> are given, this command does *not* switch
29 +When <paths> or --patch are given, this command does *not* switch
30 branches. It updates the named paths in the working tree from
31 the index file, or from a named <tree-ish> (most often a commit). In
32 this case, the `-b` and `--track` options are meaningless and giving
@@ -113,6 +114,16 @@ the conflicted merge in the specified paths.
114 "merge" (default) and "diff3" (in addition to what is shown by
115 "merge" style, shows the original contents).
116
117 +-p::
118 +--patch::
119 + Interactively select hunks in the difference between the
120 + <tree-ish> (or the index, if unspecified) and the working
121 + tree. The chosen hunks are then applied in reverse to the
122 + working tree (and if a <tree-ish> was specified, the index).
123 ++
124 +This means that you can use `git checkout -p` to selectively discard
125 +edits from your current working tree.
126 +
127 <branch>::
128 Branch to checkout; if it refers to a branch (i.e., a name that,
129 when prepended with "refs/heads/", is a valid ref), then that
builtin-checkout.c
+19
@@ -572,6 +572,13 @@ static int git_checkout_config(const char *var, const char *value, void *cb)
572 return git_xmerge_config(var, value, cb);
573 }
574
575 +static int interactive_checkout(const char *revision, const char **pathspec,
576 + struct checkout_opts *opts)
577 +{
578 + return run_add_interactive(revision, "--patch=checkout", pathspec);
579 +}
580 +
581 +
582 int cmd_checkout(int argc, const char **argv, const char *prefix)
583 {
584 struct checkout_opts opts;
@@ -580,6 +587,7 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
587 struct branch_info new;
588 struct tree *source_tree = NULL;
589 char *conflict_style = NULL;
590 + int patch_mode = 0;
591 struct option options[] = {
592 OPT__QUIET(&opts.quiet),
593 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)
602 OPT_BOOLEAN('m', "merge", &opts.merge, "merge"),
603 OPT_STRING(0, "conflict", &conflict_style, "style",
604 "conflict style (merge or diff3)"),
605 + OPT_BOOLEAN('p', "patch", &patch_mode, "select hunks interactively"),
606 OPT_END(),
607 };
608 int has_dash_dash;
@@ -608,6 +617,10 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
617 argc = parse_options(argc, argv, prefix, options, checkout_usage,
618 PARSE_OPT_KEEP_DASHDASH);
619
620 + if (patch_mode && (opts.track > 0 || opts.new_branch
621 + || opts.new_branch_log || opts.merge || opts.force))
622 + die ("--patch is incompatible with all other options");
623 +
624 /* --track without -b should DWIM */
625 if (0 < opts.track && !opts.new_branch) {
626 const char *argv0 = argv[0];
@@ -714,6 +727,9 @@ no_reference:
727 if (!pathspec)
728 die("invalid path specification");
729
730 + if (patch_mode)
731 + return interactive_checkout(new.name, pathspec, &opts);
732 +
733 /* Checkout paths */
734 if (opts.new_branch) {
735 if (argc == 1) {
@@ -729,6 +745,9 @@ no_reference:
745 return checkout_paths(source_tree, pathspec, &opts);
746 }
747
748 + if (patch_mode)
749 + return interactive_checkout(new.name, NULL, &opts);
750 +
751 if (opts.new_branch) {
752 struct strbuf buf = STRBUF_INIT;
753 if (strbuf_check_branch_ref(&buf, opts.new_branch))
git-add--interactive.perl
+61
@@ -75,6 +75,7 @@ my $patch_mode;
75 my $patch_mode_revision;
76
77 sub apply_patch;
78 +sub apply_patch_for_checkout_commit;
79
80 my %patch_modes = (
81 'stage' => {
@@ -104,6 +105,33 @@ my %patch_modes = (
105 PARTICIPLE => 'applying',
106 FILTER => 'index-only',
107 },
108 + 'checkout_index' => {
109 + DIFF => 'diff-files -p',
110 + APPLY => sub { apply_patch 'apply -R', @_; },
111 + APPLY_CHECK => 'apply -R',
112 + VERB => 'Discard',
113 + TARGET => ' from worktree',
114 + PARTICIPLE => 'discarding',
115 + FILTER => 'file-only',
116 + },
117 + 'checkout_head' => {
118 + DIFF => 'diff-index -p',
119 + APPLY => sub { apply_patch_for_checkout_commit '-R', @_ },
120 + APPLY_CHECK => 'apply -R',
121 + VERB => 'Discard',
122 + TARGET => ' from index and worktree',
123 + PARTICIPLE => 'discarding',
124 + FILTER => undef,
125 + },
126 + 'checkout_nothead' => {
127 + DIFF => 'diff-index -R -p',
128 + APPLY => sub { apply_patch_for_checkout_commit '', @_ },
129 + APPLY_CHECK => 'apply',
130 + VERB => 'Apply',
131 + TARGET => ' to index and worktree',
132 + PARTICIPLE => 'applying',
133 + FILTER => undef,
134 + },
135 );
136
137 my %patch_mode_flavour = %{$patch_modes{stage}};
@@ -1069,6 +1097,29 @@ sub apply_patch {
1097 return $ret;
1098 }
1099
1100 +sub apply_patch_for_checkout_commit {
1101 + my $reverse = shift;
1102 + my $applies_index = run_git_apply 'apply '.$reverse.' --cached --recount --check', @_;
1103 + my $applies_worktree = run_git_apply 'apply '.$reverse.' --recount --check', @_;
1104 +
1105 + if ($applies_worktree && $applies_index) {
1106 + run_git_apply 'apply '.$reverse.' --cached --recount', @_;
1107 + run_git_apply 'apply '.$reverse.' --recount', @_;
1108 + return 1;
1109 + } elsif (!$applies_index) {
1110 + print colored $error_color, "The selected hunks do not apply to the index!\n";
1111 + if (prompt_yesno "Apply them to the worktree anyway? ") {
1112 + return run_git_apply 'apply '.$reverse.' --recount', @_;
1113 + } else {
1114 + print colored $error_color, "Nothing was applied.\n";
1115 + return 0;
1116 + }
1117 + } else {
1118 + print STDERR @_;
1119 + return 0;
1120 + }
1121 +}
1122 +
1123 sub patch_update_cmd {
1124 my @all_mods = list_modified($patch_mode_flavour{FILTER});
1125 my @mods = grep { !($_->{BINARY}) } @all_mods;
@@ -1432,6 +1483,16 @@ sub process_args {
1483 'reset_head' : 'reset_nothead');
1484 $arg = shift @ARGV or die "missing --";
1485 }
1486 + } elsif ($1 eq 'checkout') {
1487 + $arg = shift @ARGV or die "missing --";
1488 + if ($arg eq '--') {
1489 + $patch_mode = 'checkout_index';
1490 + } else {
1491 + $patch_mode_revision = $arg;
1492 + $patch_mode = ($arg eq 'HEAD' ?
1493 + 'checkout_head' : 'checkout_nothead');
1494 + $arg = shift @ARGV or die "missing --";
1495 + }
1496 } elsif ($1 eq 'stage') {
1497 $patch_mode = 'stage';
1498 $arg = shift @ARGV or die "missing --";
t/t2015-checkout-patch.sh new
+107
@@ -0,0 +1,107 @@
1 +#!/bin/sh
2 +
3 +test_description='git checkout --patch'
4 +
5 +. ./lib-patch-mode.sh
6 +
7 +test_expect_success 'setup' '
8 + mkdir dir &&
9 + echo parent > dir/foo &&
10 + echo dummy > bar &&
11 + git add bar dir/foo &&
12 + git commit -m initial &&
13 + test_tick &&
14 + test_commit second dir/foo head &&
15 + set_and_save_state bar bar_work bar_index &&
16 + save_head
17 +'
18 +
19 +# note: bar sorts before dir/foo, so the first 'n' is always to skip 'bar'
20 +
21 +test_expect_success 'saying "n" does nothing' '
22 + set_and_save_state dir/foo work head &&
23 + (echo n; echo n) | git checkout -p &&
24 + verify_saved_state bar &&
25 + verify_saved_state dir/foo
26 +'
27 +
28 +test_expect_success 'git checkout -p' '
29 + (echo n; echo y) | git checkout -p &&
30 + verify_saved_state bar &&
31 + verify_state dir/foo head head
32 +'
33 +
34 +test_expect_success 'git checkout -p with staged changes' '
35 + set_state dir/foo work index
36 + (echo n; echo y) | git checkout -p &&
37 + verify_saved_state bar &&
38 + verify_state dir/foo index index
39 +'
40 +
41 +test_expect_success 'git checkout -p HEAD with NO staged changes: abort' '
42 + set_and_save_state dir/foo work head &&
43 + (echo n; echo y; echo n) | git checkout -p HEAD &&
44 + verify_saved_state bar &&
45 + verify_saved_state dir/foo
46 +'
47 +
48 +test_expect_success 'git checkout -p HEAD with NO staged changes: apply' '
49 + (echo n; echo y; echo y) | git checkout -p HEAD &&
50 + verify_saved_state bar &&
51 + verify_state dir/foo head head
52 +'
53 +
54 +test_expect_success 'git checkout -p HEAD with change already staged' '
55 + set_state dir/foo index index
56 + # the third n is to get out in case it mistakenly does not apply
57 + (echo n; echo y; echo n) | git checkout -p HEAD &&
58 + verify_saved_state bar &&
59 + verify_state dir/foo head head
60 +'
61 +
62 +test_expect_success 'git checkout -p HEAD^' '
63 + # the third n is to get out in case it mistakenly does not apply
64 + (echo n; echo y; echo n) | git checkout -p HEAD^ &&
65 + verify_saved_state bar &&
66 + verify_state dir/foo parent parent
67 +'
68 +
69 +# The idea in the rest is that bar sorts first, so we always say 'y'
70 +# first and if the path limiter fails it'll apply to bar instead of
71 +# dir/foo. There's always an extra 'n' to reject edits to dir/foo in
72 +# the failure case (and thus get out of the loop).
73 +
74 +test_expect_success 'path limiting works: dir' '
75 + set_state dir/foo work head &&
76 + (echo y; echo n) | git checkout -p dir &&
77 + verify_saved_state bar &&
78 + verify_state dir/foo head head
79 +'
80 +
81 +test_expect_success 'path limiting works: -- dir' '
82 + set_state dir/foo work head &&
83 + (echo y; echo n) | git checkout -p -- dir &&
84 + verify_saved_state bar &&
85 + verify_state dir/foo head head
86 +'
87 +
88 +test_expect_success 'path limiting works: HEAD^ -- dir' '
89 + # the third n is to get out in case it mistakenly does not apply
90 + (echo y; echo n; echo n) | git checkout -p HEAD^ -- dir &&
91 + verify_saved_state bar &&
92 + verify_state dir/foo parent parent
93 +'
94 +
95 +test_expect_success 'path limiting works: foo inside dir' '
96 + set_state dir/foo work head &&
97 + # the third n is to get out in case it mistakenly does not apply
98 + (echo y; echo n; echo n) | (cd dir && git checkout -p foo) &&
99 + verify_saved_state bar &&
100 + verify_state dir/foo head head
101 +'
102 +
103 +test_expect_success 'none of this moved HEAD' '
104 + verify_saved_head
105 +'
106 +
107 +test_done