Skip to content

Commit 4f35365

Browse files
trastgitster
authored andcommitted
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 <[email protected]> Signed-off-by: Junio C Hamano <[email protected]>
1 parent d002ef4 commit 4f35365

File tree

4 files changed

+199
-1
lines changed

4 files changed

+199
-1
lines changed

Documentation/git-checkout.txt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ SYNOPSIS
1111
'git checkout' [-q] [-f] [-m] [<branch>]
1212
'git checkout' [-q] [-f] [-m] [-b <new_branch>] [<start_point>]
1313
'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] [<tree-ish>] [--] <paths>...
14+
'git checkout' --patch [<tree-ish>] [--] [<paths>...]
1415

1516
DESCRIPTION
1617
-----------
@@ -25,7 +26,7 @@ use the --track or --no-track options, which will be passed to `git
2526
branch`. As a convenience, --track without `-b` implies branch
2627
creation; see the description of --track below.
2728

28-
When <paths> are given, this command does *not* switch
29+
When <paths> or --patch are given, this command does *not* switch
2930
branches. It updates the named paths in the working tree from
3031
the index file, or from a named <tree-ish> (most often a commit). In
3132
this case, the `-b` and `--track` options are meaningless and giving
@@ -113,6 +114,16 @@ the conflicted merge in the specified paths.
113114
"merge" (default) and "diff3" (in addition to what is shown by
114115
"merge" style, shows the original contents).
115116

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+
116127
<branch>::
117128
Branch to checkout; if it refers to a branch (i.e., a name that,
118129
when prepended with "refs/heads/", is a valid ref), then that

builtin-checkout.c

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,13 @@ static int git_checkout_config(const char *var, const char *value, void *cb)
572572
return git_xmerge_config(var, value, cb);
573573
}
574574

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+
575582
int cmd_checkout(int argc, const char **argv, const char *prefix)
576583
{
577584
struct checkout_opts opts;
@@ -580,6 +587,7 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
580587
struct branch_info new;
581588
struct tree *source_tree = NULL;
582589
char *conflict_style = NULL;
590+
int patch_mode = 0;
583591
struct option options[] = {
584592
OPT__QUIET(&opts.quiet),
585593
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)
594602
OPT_BOOLEAN('m', "merge", &opts.merge, "merge"),
595603
OPT_STRING(0, "conflict", &conflict_style, "style",
596604
"conflict style (merge or diff3)"),
605+
OPT_BOOLEAN('p', "patch", &patch_mode, "select hunks interactively"),
597606
OPT_END(),
598607
};
599608
int has_dash_dash;
@@ -608,6 +617,10 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
608617
argc = parse_options(argc, argv, prefix, options, checkout_usage,
609618
PARSE_OPT_KEEP_DASHDASH);
610619

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+
611624
/* --track without -b should DWIM */
612625
if (0 < opts.track && !opts.new_branch) {
613626
const char *argv0 = argv[0];
@@ -714,6 +727,9 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
714727
if (!pathspec)
715728
die("invalid path specification");
716729

730+
if (patch_mode)
731+
return interactive_checkout(new.name, pathspec, &opts);
732+
717733
/* Checkout paths */
718734
if (opts.new_branch) {
719735
if (argc == 1) {
@@ -729,6 +745,9 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
729745
return checkout_paths(source_tree, pathspec, &opts);
730746
}
731747

748+
if (patch_mode)
749+
return interactive_checkout(new.name, NULL, &opts);
750+
732751
if (opts.new_branch) {
733752
struct strbuf buf = STRBUF_INIT;
734753
if (strbuf_check_branch_ref(&buf, opts.new_branch))

git-add--interactive.perl

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ sub colored {
7575
my $patch_mode_revision;
7676

7777
sub apply_patch;
78+
sub apply_patch_for_checkout_commit;
7879

7980
my %patch_modes = (
8081
'stage' => {
@@ -104,6 +105,33 @@ sub colored {
104105
PARTICIPLE => 'applying',
105106
FILTER => 'index-only',
106107
},
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+
},
107135
);
108136

109137
my %patch_mode_flavour = %{$patch_modes{stage}};
@@ -1069,6 +1097,29 @@ sub apply_patch {
10691097
return $ret;
10701098
}
10711099

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+
10721123
sub patch_update_cmd {
10731124
my @all_mods = list_modified($patch_mode_flavour{FILTER});
10741125
my @mods = grep { !($_->{BINARY}) } @all_mods;
@@ -1432,6 +1483,16 @@ sub process_args {
14321483
'reset_head' : 'reset_nothead');
14331484
$arg = shift @ARGV or die "missing --";
14341485
}
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+
}
14351496
} elsif ($1 eq 'stage') {
14361497
$patch_mode = 'stage';
14371498
$arg = shift @ARGV or die "missing --";

t/t2015-checkout-patch.sh

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)