[PATCH v4 4/5] Implement 'git checkout --patch'

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

 



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

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

which offers hunks from the difference between the <revision> and the
worktree.  The chosen hunks are then reverse applied to both index and
worktree, discarding them completely.  This application 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@xxxxxxxxxxxxxxx>
---
 Documentation/git-checkout.txt |   13 ++++++-
 builtin-checkout.c             |   19 +++++++++
 git-add--interactive.perl      |   48 +++++++++++++++++++++++
 t/t2015-checkout-patch.sh      |   84 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 163 insertions(+), 1 deletions(-)
 create mode 100755 t/t2015-checkout-patch.sh

diff --git a/Documentation/git-checkout.txt b/Documentation/git-checkout.txt
index ad4b31e..26a5447 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 446cac7..7d57741 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 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
 		if (!pathspec)
 			die("invalid path specification");
 
+		if (patch_mode)
+			return interactive_checkout(NULL, pathspec, &opts);
+
 		/* Checkout paths */
 		if (opts.new_branch) {
 			if (argc == 1) {
@@ -729,6 +745,9 @@ int cmd_checkout(int argc, const char **argv, const char *prefix)
 		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 333e80d..bb76c37 100755
--- a/git-add--interactive.perl
+++ b/git-add--interactive.perl
@@ -75,6 +75,7 @@
 my $patch_mode_revision;
 
 sub apply_patch;
+sub apply_patch_for_checkout_commit;
 
 my %patch_modes = (
 	'stage' => {
@@ -93,6 +94,22 @@
 		PARTICIPLE => 'resetting',
 		FILTER => 'index-only',
 	},
+	'checkout_index' => {
+		DIFF => 'diff-files -p',
+		APPLY => sub { apply_patch 'apply -R', @_; },
+		APPLY_CHECK => 'apply -R',
+		VERB => 'Check out',
+		PARTICIPLE => 'checking out',
+		FILTER => 'file-only',
+	},
+	'checkout_commit' => {
+		DIFF => 'diff-index -p',
+		APPLY => \&apply_patch_for_checkout_commit,
+		APPLY_CHECK => 'apply -R',
+		VERB => 'Check out',
+		PARTICIPLE => 'checking out',
+		FILTER => undef,
+	},
 );
 
 my %patch_mode_flavour = %{$patch_modes{stage}};
@@ -1057,6 +1074,28 @@
 	return $ret;
 }
 
+sub apply_patch_for_checkout_commit {
+	my $applies_index = run_git_apply 'apply -R --cached --recount --check', @_;
+	my $applies_worktree = run_git_apply 'apply -R --recount --check', @_;
+
+	if ($applies_worktree && $applies_index) {
+		run_git_apply 'apply -R --cached --recount', @_;
+		run_git_apply 'apply -R --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 -R --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;
@@ -1417,6 +1456,15 @@
 					$patch_mode_revision = $arg;
 					$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 = 'checkout_commit';
+					$patch_mode_revision = $arg;
+					$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 0000000..bb96652
--- /dev/null
+++ b/t/t2015-checkout-patch.sh
@@ -0,0 +1,84 @@
+#!/bin/sh
+
+test_description='git checkout --patch'
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+	mkdir dir &&
+	echo parent > dir/foo &&
+	echo dummy > dir/bar &&
+	git add dir &&
+	git commit -m initial &&
+	test_tick &&
+	test_commit second dir/foo head &&
+	echo work > dir/foo &&
+	echo bar_index > dir/bar &&
+	git add dir/bar &&
+	echo bar_work > dir/bar
+'
+
+# note: bar sorts before foo, so the first 'n' is always to skip 'bar'
+
+test_expect_success 'saying "n" does nothing' '
+	(echo n; echo n) | git checkout -p &&
+	test "$(cat dir/foo)" = work
+'
+
+test_expect_success 'git checkout -p' '
+	
+	(echo n; echo y) | git checkout -p &&
+	test "$(cat dir/foo)" = head &&
+	test "$(git show :dir/bar)" = bar_index &&
+	test "$(cat dir/bar)" = bar_work
+'
+
+test_expect_success 'git checkout -p with staged changes' '
+	echo index > dir/foo &&
+	git add dir/foo &&
+	echo work > dir/foo &&
+	(echo n; echo y) | git checkout -p &&
+	test "$(git show :dir/foo)" = index &&
+	test "$(cat dir/foo)" = index &&
+	test "$(git show :dir/bar)" = bar_index &&
+	test "$(cat dir/bar)" = bar_work
+'
+
+test_expect_success 'git checkout -p HEAD with NO staged changes: abort' '
+	git reset -q -- dir/foo &&
+	echo work > dir/foo &&
+	(echo n; echo y; echo n) | git checkout -p HEAD &&
+	test "$(git show :dir/foo)" = head &&
+	test "$(cat dir/foo)" = work &&
+	test "$(git show :dir/bar)" = bar_index &&
+	test "$(cat dir/bar)" = bar_work
+'
+
+test_expect_success 'git checkout -p HEAD with NO staged changes: apply' '
+	(echo n; echo y; echo y) | git checkout -p HEAD &&
+	test "$(git show :dir/foo)" = head &&
+	test "$(cat dir/foo)" = head &&
+	test "$(git show :dir/bar)" = bar_index &&
+	test "$(cat dir/bar)" = bar_work
+'
+
+test_expect_success 'git checkout -p HEAD with change already staged' '
+	echo index > dir/foo &&
+	git add dir/foo &&
+	# the third n is to get out in case it mistakenly does not apply
+	(echo n; echo y; echo n) | git checkout -p HEAD &&
+	test "$(git show :dir/foo)" = head &&
+	test "$(cat dir/foo)" = head &&
+	test "$(git show :dir/bar)" = bar_index &&
+	test "$(cat dir/bar)" = bar_work
+'
+
+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^ &&
+	test "$(git show :dir/foo)" = parent &&
+	test "$(cat dir/foo)" = parent &&
+	test "$(git show :dir/bar)" = bar_index &&
+	test "$(cat dir/bar)" = bar_work
+'
+
+test_done
-- 
1.6.4.rc3.215.g18405

--
To unsubscribe from this list: send the line "unsubscribe git" in
the body of a message to majordomo@xxxxxxxxxxxxxxx
More majordomo info at  http://vger.kernel.org/majordomo-info.html

[Index of Archives]     [Linux Kernel Development]     [Gcc Help]     [IETF Annouce]     [DCCP]     [Netdev]     [Networking]     [Security]     [V4L]     [Bugtraq]     [Yosemite]     [MIPS Linux]     [ARM Linux]     [Linux Security]     [Linux RAID]     [Linux SCSI]     [Fedora Users]