From 21b138d0f64d99b55e901f90d4211e036b881e64 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Mon, 1 Dec 2014 13:57:28 -0800 Subject: [PATCH 1/2] receive-pack: refactor updateInstead codepath Keep the "there is nothing to update in a bare repository", "when the check and update process runs, here are the GIT_DIR and GIT_WORK_TREE" logic, which will be common regardless of how the decision to update and the actual update are done, in the original update_worktree() function, and split out the "working tree and the index must match the original HEAD exactly" and "use two-way read-tree to update the working tree" into a new push_to_deploy() helper function. This will allow customizing the logic more cleanly and easily. Signed-off-by: Junio C Hamano --- builtin/receive-pack.c | 107 +++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index c04741878d..11800cda2e 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -733,7 +733,9 @@ static int update_shallow_ref(struct command *cmd, struct shallow_info *si) return 0; } -static const char *update_worktree(unsigned char *sha1) +static const char *push_to_deploy(unsigned char *sha1, + struct argv_array *env, + const char *work_tree) { const char *update_refresh[] = { "update-index", "-q", "--ignore-submodules", "--refresh", NULL @@ -748,67 +750,68 @@ static const char *update_worktree(unsigned char *sha1) const char *read_tree[] = { "read-tree", "-u", "-m", NULL, NULL }; + struct child_process child = CHILD_PROCESS_INIT; + + child.argv = update_refresh; + child.env = env->argv; + child.dir = work_tree; + child.no_stdin = 1; + child.stdout_to_stderr = 1; + child.git_cmd = 1; + if (run_command(&child)) + return "Up-to-date check failed"; + + /* run_command() does not clean up completely; reinitialize */ + child_process_init(&child); + child.argv = diff_files; + child.env = env->argv; + child.dir = work_tree; + child.no_stdin = 1; + child.stdout_to_stderr = 1; + child.git_cmd = 1; + if (run_command(&child)) + return "Working directory has unstaged changes"; + + child_process_init(&child); + child.argv = diff_index; + child.env = env->argv; + child.no_stdin = 1; + child.no_stdout = 1; + child.stdout_to_stderr = 0; + child.git_cmd = 1; + if (run_command(&child)) + return "Working directory has staged changes"; + + read_tree[3] = sha1_to_hex(sha1); + child_process_init(&child); + child.argv = read_tree; + child.env = env->argv; + child.dir = work_tree; + child.no_stdin = 1; + child.no_stdout = 1; + child.stdout_to_stderr = 0; + child.git_cmd = 1; + if (run_command(&child)) + return "Could not update working tree to new HEAD"; + + return NULL; +} + +static const char *update_worktree(unsigned char *sha1) +{ + const char *retval; const char *work_tree = git_work_tree_cfg ? git_work_tree_cfg : ".."; struct argv_array env = ARGV_ARRAY_INIT; - struct child_process child = CHILD_PROCESS_INIT; if (is_bare_repository()) return "denyCurrentBranch = updateInstead needs a worktree"; argv_array_pushf(&env, "GIT_DIR=%s", absolute_path(get_git_dir())); - child.argv = update_refresh; - child.env = env.argv; - child.dir = work_tree; - child.no_stdin = 1; - child.stdout_to_stderr = 1; - child.git_cmd = 1; - if (run_command(&child)) { - argv_array_clear(&env); - return "Up-to-date check failed"; - } - - /* run_command() does not clean up completely; reinitialize */ - child_process_init(&child); - child.argv = diff_files; - child.env = env.argv; - child.dir = work_tree; - child.no_stdin = 1; - child.stdout_to_stderr = 1; - child.git_cmd = 1; - if (run_command(&child)) { - argv_array_clear(&env); - return "Working directory has unstaged changes"; - } - - child_process_init(&child); - child.argv = diff_index; - child.env = env.argv; - child.no_stdin = 1; - child.no_stdout = 1; - child.stdout_to_stderr = 0; - child.git_cmd = 1; - if (run_command(&child)) { - argv_array_clear(&env); - return "Working directory has staged changes"; - } - - read_tree[3] = sha1_to_hex(sha1); - child_process_init(&child); - child.argv = read_tree; - child.env = env.argv; - child.dir = work_tree; - child.no_stdin = 1; - child.no_stdout = 1; - child.stdout_to_stderr = 0; - child.git_cmd = 1; - if (run_command(&child)) { - argv_array_clear(&env); - return "Could not update working tree to new HEAD"; - } + retval = push_to_deploy(sha1, &env, work_tree); argv_array_clear(&env); - return NULL; + return retval; } static const char *update(struct command *cmd, struct shallow_info *si) From 0855331941b723b227e93b33955bbe0b45025659 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Mon, 1 Dec 2014 15:29:54 -0800 Subject: [PATCH 2/2] receive-pack: support push-to-checkout hook When receive.denyCurrentBranch is set to updateInstead, a push that tries to update the branch that is currently checked out is accepted only when the index and the working tree exactly matches the currently checked out commit, in which case the index and the working tree are updated to match the pushed commit. Otherwise the push is refused. This hook can be used to customize this "push-to-deploy" logic. The hook receives the commit with which the tip of the current branch is going to be updated, and can decide what kind of local changes are acceptable and how to update the index and the working tree to match the updated tip of the current branch. For example, the hook can simply run `git read-tree -u -m HEAD "$1"` in order to emulate 'git fetch' that is run in the reverse direction with `git push`, as the two-tree form of `read-tree -u -m` is essentially the same as `git checkout` that switches branches while keeping the local changes in the working tree that do not interfere with the difference between the branches. Signed-off-by: Junio C Hamano --- Documentation/config.txt | 6 +++- Documentation/githooks.txt | 30 ++++++++++++++++++ builtin/receive-pack.c | 19 +++++++++++- t/t5516-fetch-push.sh | 63 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/Documentation/config.txt b/Documentation/config.txt index 6a4c19e775..4e558abc73 100644 --- a/Documentation/config.txt +++ b/Documentation/config.txt @@ -2131,11 +2131,15 @@ receive.denyCurrentBranch:: message. Defaults to "refuse". + Another option is "updateInstead" which will update the working -directory (must be clean) if pushing into the current branch. This option is +tree if pushing into the current branch. This option is intended for synchronizing working directories when one side is not easily accessible via interactive ssh (e.g. a live web site, hence the requirement that the working directory be clean). This mode also comes in handy when developing inside a VM to test and fix code on different Operating Systems. ++ +By default, "updateInstead" will refuse the push if the working tree or +the index have any difference from the HEAD, but the `push-to-checkout` +hook can be used to customize this. See linkgit:githooks[5]. receive.denyNonFastForwards:: If set to true, git-receive-pack will deny a ref update which is diff --git a/Documentation/githooks.txt b/Documentation/githooks.txt index 9ef2469373..7ba0ac965d 100644 --- a/Documentation/githooks.txt +++ b/Documentation/githooks.txt @@ -341,6 +341,36 @@ Both standard output and standard error output are forwarded to 'git send-pack' on the other end, so you can simply `echo` messages for the user. +push-to-checkout +~~~~~~~~~~~~~~~~ + +This hook is invoked by 'git-receive-pack' on the remote repository, +which happens when a 'git push' is done on a local repository, when +the push tries to update the branch that is currently checked out +and the `receive.denyCurrentBranch` configuration variable is set to +`updateInstead`. Such a push by default is refused if the working +tree and the index of the remote repository has any difference from +the currently checked out commit; when both the working tree and the +index match the current commit, they are updated to match the newly +pushed tip of the branch. This hook is to be used to override the +default behaviour. + +The hook receives the commit with which the tip of the current +branch is going to be updated. It can exit with a non-zero status +to refuse the push (when it does so, it must not modify the index or +the working tree). Or it can make any necessary changes to the +working tree and to the index to bring them to the desired state +when the tip of the current branch is updated to the new commit, and +exit with a zero status. + +For example, the hook can simply run `git read-tree -u -m HEAD "$1"` +in order to emulate 'git fetch' that is run in the reverse direction +with `git push`, as the two-tree form of `read-tree -u -m` is +essentially the same as `git checkout` that switches branches while +keeping the local changes in the working tree that do not interfere +with the difference between the branches. + + pre-auto-gc ~~~~~~~~~~~ diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c index 11800cda2e..fc8ec9c3fa 100644 --- a/builtin/receive-pack.c +++ b/builtin/receive-pack.c @@ -797,6 +797,20 @@ static const char *push_to_deploy(unsigned char *sha1, return NULL; } +static const char *push_to_checkout_hook = "push-to-checkout"; + +static const char *push_to_checkout(unsigned char *sha1, + struct argv_array *env, + const char *work_tree) +{ + argv_array_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree)); + if (run_hook_le(env->argv, push_to_checkout_hook, + sha1_to_hex(sha1), NULL)) + return "push-to-checkout hook declined"; + else + return NULL; +} + static const char *update_worktree(unsigned char *sha1) { const char *retval; @@ -808,7 +822,10 @@ static const char *update_worktree(unsigned char *sha1) argv_array_pushf(&env, "GIT_DIR=%s", absolute_path(get_git_dir())); - retval = push_to_deploy(sha1, &env, work_tree); + if (!find_hook(push_to_checkout_hook)) + retval = push_to_deploy(sha1, &env, work_tree); + else + retval = push_to_checkout(sha1, &env, work_tree); argv_array_clear(&env); return retval; diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh index 85c7fecd22..e4436c1700 100755 --- a/t/t5516-fetch-push.sh +++ b/t/t5516-fetch-push.sh @@ -1434,4 +1434,67 @@ test_expect_success 'receive.denyCurrentBranch = updateInstead' ' ' +test_expect_success 'updateInstead with push-to-checkout hook' ' + rm -fr testrepo && + git init testrepo && + ( + cd testrepo && + git pull .. master && + git reset --hard HEAD^^ && + git tag initial && + git config receive.denyCurrentBranch updateInstead && + write_script .git/hooks/push-to-checkout <<-\EOF + echo >&2 updating from $(git rev-parse HEAD) + echo >&2 updating to "$1" + + git update-index -q --refresh && + git read-tree -u -m HEAD "$1" || { + status=$? + echo >&2 read-tree failed + exit $status + } + EOF + ) && + + # Try pushing into a pristine + git push testrepo master && + ( + cd testrepo && + git diff --quiet && + git diff HEAD --quiet && + test $(git -C .. rev-parse HEAD) = $(git rev-parse HEAD) + ) && + + # Try pushing into a repository with conflicting change + ( + cd testrepo && + git reset --hard initial && + echo conflicting >path2 + ) && + test_must_fail git push testrepo master && + ( + cd testrepo && + test $(git rev-parse initial) = $(git rev-parse HEAD) && + test conflicting = "$(cat path2)" && + git diff-index --quiet --cached HEAD + ) && + + # Try pushing into a repository with unrelated change + ( + cd testrepo && + git reset --hard initial && + echo unrelated >path1 && + echo irrelevant >path5 && + git add path5 + ) && + git push testrepo master && + ( + cd testrepo && + test "$(cat path1)" = unrelated && + test "$(cat path5)" = irrelevant && + test "$(git diff --name-only --cached HEAD)" = path5 && + test $(git -C .. rev-parse HEAD) = $(git rev-parse HEAD) + ) +' + test_done