diff mbox series

[1/5] run-command: add preliminary support for multiple hooks

Message ID 20190424004948.728326-2-sandals@crustytoothpaste.net (mailing list archive)
State New, archived
Headers show
Series Multiple hook support | expand

Commit Message

brian m. carlson April 24, 2019, 12:49 a.m. UTC
A variety of types of software take advantage of Git's hooks. However,
if a user would like to integrate multiple pieces of software which use
a particular hook, they currently must manage those hooks themselves,
which can be burdensome. Sometimes various pieces of software try to
overwrite each other's hooks, leading to problems.

To solve this problem, introduce a framework for running multiple hooks
using a ".d" directory named similarly to the hook, running each hook in
order sorted by name. Wire this framework up for those functions using
run_hook_le or run_hook_ve. To preserve backwards compatibility, ensure
that multiple hooks run only if there is no hook using the current hook
style.

If we are running multiple hooks and one of them exits nonzero, don't
execute the remaining hooks and return that exit code immediately. This
allows hooks to fail fast and it avoids having to deal with what happens
if multiple hooks fail with different exit statuses.

Create a test framework for testing multiple hooks with different
commands. This is necessary because not all hooks use run_hook_ve or
run_hook_le and we'll want to ensure all the various hooks work without
needing to write lots of duplicative test code.

Test the pre-commit hook to verify that the run_hook_ve implementation
works correctly.

Signed-off-by: brian m. carlson <sandals@crustytoothpaste.net>
---
 builtin/commit.c           |   5 +-
 run-command.c              | 141 +++++++++++++++++++++++++--------
 run-command.h              |   7 ++
 t/lib-hooks.sh             | 156 +++++++++++++++++++++++++++++++++++++
 t/t7503-pre-commit-hook.sh |  15 ++++
 5 files changed, 292 insertions(+), 32 deletions(-)
 create mode 100644 t/lib-hooks.sh

Comments

Junio C Hamano April 24, 2019, 2:27 a.m. UTC | #1
"brian m. carlson" <sandals@crustytoothpaste.net> writes:

> diff --git a/builtin/commit.c b/builtin/commit.c
> index f17537474a..e7cf6b16ba 100644
> --- a/builtin/commit.c
> +++ b/builtin/commit.c
> @@ -666,6 +666,7 @@ static int prepare_to_commit(const char *index_file, const char *prefix,
>  	struct strbuf sb = STRBUF_INIT;
>  	const char *hook_arg1 = NULL;
>  	const char *hook_arg2 = NULL;
> +	struct string_list *hooks;
>  	int clean_message_contents = (cleanup_mode != COMMIT_MSG_CLEANUP_NONE);
>  	int old_display_comment_prefix;
>  
> @@ -943,13 +944,15 @@ static int prepare_to_commit(const char *index_file, const char *prefix,
>  		return 0;
>  	}
>  
> -	if (!no_verify && find_hook("pre-commit")) {
> +	hooks = find_hooks("pre-commit");
> +	if (!no_verify && hooks) {
>  		/*
>  		 * Re-read the index as pre-commit hook could have updated it,
>  		 * and write it out as a tree.  We must do this before we invoke
>  		 * the editor and after we invoke run_status above.
>  		 */
>  		discard_cache();
> +		free_hooks(hooks);
>  	}
>  	read_cache_from(index_file);

OK, so find_hook() that used to return a single hook now can return
a list of hook scripts.  Running the single one becomes a simple
special case of "run each of them in turn, and stop at the first
failure".

> diff --git a/run-command.c b/run-command.c
> index 3449db319b..669af5ebc7 100644
> --- a/run-command.c
> +++ b/run-command.c
> @@ -1308,58 +1308,137 @@ int async_with_fork(void)
>  #endif
>  }
>  
> +static int has_hook(struct strbuf *path, int strip)
> +{
> +	if (access(path->buf, X_OK) < 0) {

Does ".git/post-commit" that is not an executable exist?

It was perfectly fine for find_hook() to say "there is no hook for
post-commit" in the old world in such a case, because the
unexecutable file it found is not going to be run anyway.

But it is not clear if has_hook(), that affects "there is no single
hook file for post-commit, so let's look at post-commit.d" decision
made by find_hooks(), should behave that way.  It somehow feels more
intuitive if a post-commit file that is not executable, by merely
existing, stops post-commit.d directory from being scanned, at least
to me.

>  int run_hook_ve(const char *const *env, const char *name, va_list args)
>  {
> -	struct child_process hook = CHILD_PROCESS_INIT;
> +	struct string_list *hooks;
> +	struct string_list arglist = STRING_LIST_INIT_NODUP;
>  	const char *p;
> +	struct string_list_item *q;
> +	int ret = 0;
>   ...
> +		hook.env = env;
> +		hook.no_stdin = 1;
> +		hook.stdout_to_stderr = 1;
> +		hook.trace2_hook_name = name;
> +
> +		ret = run_command(&hook);
> +		if (ret)
> +			break;
> +	}
> +	string_list_clear(&arglist, 0);
> +	free_hooks(hooks);
> +	return ret;
>  }

These "run with command line arguments as its sole input, with the
exit status as its sole output" style hooks are easily handled and
the above looks like reasonable enhancement to the existing
abstraction (e.g. run 'prepare-commit-msg' hook with these
arguments).

I however wonder how the hooks in the other style should/can be
handled, that are fed data from their standard input stream, and
returns more than one bit via their standard output stream.  In
any case, they are not in the scope of this step.
Johannes Sixt April 24, 2019, 6:48 p.m. UTC | #2
Am 24.04.19 um 04:27 schrieb Junio C Hamano:
> "brian m. carlson" <sandals@crustytoothpaste.net> writes:
>> +static int has_hook(struct strbuf *path, int strip)
>> +{
>> +	if (access(path->buf, X_OK) < 0) {
> 
> Does ".git/post-commit" that is not an executable exist?
> 
> It was perfectly fine for find_hook() to say "there is no hook for
> post-commit" in the old world in such a case, because the
> unexecutable file it found is not going to be run anyway.
> 
> But it is not clear if has_hook(), that affects "there is no single
> hook file for post-commit, so let's look at post-commit.d" decision
> made by find_hooks(), should behave that way.  It somehow feels more
> intuitive if a post-commit file that is not executable, by merely
> existing, stops post-commit.d directory from being scanned, at least
> to me.

Furthermore, basing a decision on whether a file is executable won't
work on Windows as intended. So, it is better to aim for an existence check.

-- Hannes
brian m. carlson April 24, 2019, 10:32 p.m. UTC | #3
On Wed, Apr 24, 2019 at 11:27:59AM +0900, Junio C Hamano wrote:
> "brian m. carlson" <sandals@crustytoothpaste.net> writes:
> > diff --git a/run-command.c b/run-command.c
> > index 3449db319b..669af5ebc7 100644
> > --- a/run-command.c
> > +++ b/run-command.c
> > @@ -1308,58 +1308,137 @@ int async_with_fork(void)
> >  #endif
> >  }
> >  
> > +static int has_hook(struct strbuf *path, int strip)
> > +{
> > +	if (access(path->buf, X_OK) < 0) {
> 
> Does ".git/post-commit" that is not an executable exist?
> 
> It was perfectly fine for find_hook() to say "there is no hook for
> post-commit" in the old world in such a case, because the
> unexecutable file it found is not going to be run anyway.
> 
> But it is not clear if has_hook(), that affects "there is no single
> hook file for post-commit, so let's look at post-commit.d" decision
> made by find_hooks(), should behave that way.  It somehow feels more
> intuitive if a post-commit file that is not executable, by merely
> existing, stops post-commit.d directory from being scanned, at least
> to me.

Unfortunately, we used to have Git versions that wrote an non-executable
file in place instead of a ".sample" file when initializing the
repository. We have a warning for that, but I have many repositories
that have lived long enough that they're still affected (and I've turned
off the warning for that reason). I feel like we'll be creating
surprising behavior for long-term users.

Also, if someone is using an old manager script that uses the ".d"
directory or some other workaround, it's easy enough for them to simply
clear the executable bit; they need not delete it, and can restore it at
any time simply by toggling the bit.
Junio C Hamano April 25, 2019, 12:55 a.m. UTC | #4
Johannes Sixt <j6t@kdbg.org> writes:

> Furthermore, basing a decision on whether a file is executable won't
> work on Windows as intended. So, it is better to aim for an existence check.

That is a good point.

So it may be OK for "do we have a single hook script for this hook
name?" to say "no" when the path exists but not executable on
POSIXPERM systems, but it is better to say "yes" for consistency
across platforms (I think that is one of the reasons why we use
.sample suffix these days).

And for the same reason, for the purpose of deciding "because we do
not have a single hook script, let's peek into .d directory
ourselves", mere presence of the file with that name, regardless of
the executable bit, should signal that we should not handle the .d
directory.

IOW, you think access(X_OK) should be more like access(F_OK)?
Ævar Arnfjörð Bjarmason April 25, 2019, 9:39 a.m. UTC | #5
On Thu, Apr 25 2019, Junio C Hamano wrote:

> Johannes Sixt <j6t@kdbg.org> writes:
>
>> Furthermore, basing a decision on whether a file is executable won't
>> work on Windows as intended. So, it is better to aim for an existence check.
>
> That is a good point.
>
> So it may be OK for "do we have a single hook script for this hook
> name?" to say "no" when the path exists but not executable on
> POSIXPERM systems, but it is better to say "yes" for consistency
> across platforms (I think that is one of the reasons why we use
> .sample suffix these days).
>
> And for the same reason, for the purpose of deciding "because we do
> not have a single hook script, let's peek into .d directory
> ourselves", mere presence of the file with that name, regardless of
> the executable bit, should signal that we should not handle the .d
> directory.
>
> IOW, you think access(X_OK) should be more like access(F_OK)?

To me this is another point in favor of bypassing this problem entirely
and adopting the semantics GitLab (and it seems others) use. I.e. in
order execute:

    .git/hooks/pre-receive .git/hooks/pre-receive.d/*

Instead of going further down this avenue of:

    if exists_or_executable_or_whatever .git/hooks/pre-receive
    then
        .git/hooks/pre-receive
    else
        for hook in .git/hooks/pre-receive.d/*
        do
            $hook
        done
    fi

It also:

 1) Makes it easier for users to experiment with more granular hooks if
    they have one big pre-receive hook by adding pre-receive.d/* hooks
    without having to move their existing pre-receive to
    pre-receive.d/000-existing hook (which will be incompatible across
    git versions!).

 2) Is compatible with any existing trampoline scripts you might want to
    migrate from that *don't* use pre-receive.d/*, e.g. one script in
    our infrastructure (that I didn't write) does:

        my ($hook_phase, $dir) = fileparse($0);
        my $exit = 0;
        my @hooks = glob("${dir}${hook_phase}-*");
        for my $hook (@hooks) {
            next unless -x $hook;
            $exit |= system $hook;
        }
        exit ($exit >> 8);

   I.e. you have a ".git/hooks/pre-receive" trampoline and it runs
   ".git/hooks/pre-receive-*" scripts.

It occurs to me that we might want to make things configurable for the
#2 case. I.e. have a core.hooksDSuffix=".d/" by default, but you could
also set it to "-". So we'd then construct a glob of either
"pre-receive.d/*" or "pre-receive-*" from that.
Junio C Hamano April 25, 2019, 10:04 a.m. UTC | #6
Ævar Arnfjörð Bjarmason <avarab@gmail.com> writes:

> To me this is another point in favor of bypassing this problem entirely
> and adopting the semantics GitLab (and it seems others) use. I.e. in
> order execute:
>
>     .git/hooks/pre-receive .git/hooks/pre-receive.d/*

But isn't that exactly what Brian wanted to avoid?  Consider those
who run the current version of Git (which considers pre-receive.d/
is just a floating cruft Git does not care), with their own
implementation of pre-receive hook that goes over the contents of
pre-receive.d/ directory and executes each in order.  If a new
version of Git you release runs pre-receive followed by each of the
files in pre-receive.d/ directory, especially because you designed
to use *.d convention to match existing practice, doesn't your new
version of Git end up running the scripts in *.d directory twice?
Johannes Sixt April 25, 2019, 7:40 p.m. UTC | #7
Am 25.04.19 um 02:55 schrieb Junio C Hamano:
> Johannes Sixt <j6t@kdbg.org> writes:
> 
>> Furthermore, basing a decision on whether a file is executable won't
>> work on Windows as intended. So, it is better to aim for an existence check.
> 
> That is a good point.
> 
> So it may be OK for "do we have a single hook script for this hook
> name?" to say "no" when the path exists but not executable on
> POSIXPERM systems, but it is better to say "yes" for consistency
> across platforms (I think that is one of the reasons why we use
> .sample suffix these days).

All correct.

> And for the same reason, for the purpose of deciding "because we do
> not have a single hook script, let's peek into .d directory
> ourselves", mere presence of the file with that name, regardless of
> the executable bit, should signal that we should not handle the .d
> directory.
> 
> IOW, you think access(X_OK) should be more like access(F_OK)?

Yes, that's my conclusion.

-- Hannes
brian m. carlson April 26, 2019, 8:58 p.m. UTC | #8
On Thu, Apr 25, 2019 at 09:40:34PM +0200, Johannes Sixt wrote:
> Am 25.04.19 um 02:55 schrieb Junio C Hamano:
> > Johannes Sixt <j6t@kdbg.org> writes:
> > 
> >> Furthermore, basing a decision on whether a file is executable won't
> >> work on Windows as intended. So, it is better to aim for an existence check.
> > 
> > That is a good point.
> > 
> > So it may be OK for "do we have a single hook script for this hook
> > name?" to say "no" when the path exists but not executable on
> > POSIXPERM systems, but it is better to say "yes" for consistency
> > across platforms (I think that is one of the reasons why we use
> > .sample suffix these days).
> 
> All correct.

I would like to point out that we still have to perform an executability
check before we run the hook or we'll get errors printed to the user. As
I mentioned, there are many people with repositories that have the
non-.sample files. For me, any repository older than about five years
will likely have those files.

> > And for the same reason, for the purpose of deciding "because we do
> > not have a single hook script, let's peek into .d directory
> > ourselves", mere presence of the file with that name, regardless of
> > the executable bit, should signal that we should not handle the .d
> > directory.
> > 
> > IOW, you think access(X_OK) should be more like access(F_OK)?
> 
> Yes, that's my conclusion.

Right now, we have a standard way to handle the way we handle hooks: if
they are not executable, we warn and pretend there's no hook. With this
new paradigm, we have to check whether the main hook is executable, and
if it's not, we then have to check whether it's present, and if so, we
skip the multiple hooks.

I understand the executable bit is not useful on Windows, but on Unix,
we should be consistent with how we treat the hooks.
Johannes Sixt April 26, 2019, 9:53 p.m. UTC | #9
Am 26.04.19 um 22:58 schrieb brian m. carlson:
> On Thu, Apr 25, 2019 at 09:40:34PM +0200, Johannes Sixt wrote:
> I would like to point out that we still have to perform an executability
> check before we run the hook or we'll get errors printed to the user.

That's fine. On Windows, when a hook is present, it is also executable.

> Right now, we have a standard way to handle the way we handle hooks: if
> they are not executable, we warn and pretend there's no hook. With this
> new paradigm, we have to check whether the main hook is executable, and
> if it's not, we then have to check whether it's present, and if so, we
> skip the multiple hooks.
> 
> I understand the executable bit is not useful on Windows, but on Unix,
> we should be consistent with how we treat the hooks.

We want to check for two vastly different conditions:

- Do we have to inspect the multi-hook directory? That decision should
be based on existence.

- Do we have to issue a warning? That can be based on the executable
flag. (As I understand, this is just a convenience warning because we do
not want the user to see a cryptic "cannot execute this thing" error or
something.)

I can see that you sense an inconsistency when you treat "not
executable" as "does not exist". But that is just too subtle in my book,
hard to explain, and not the practice that we are exercising these days.

I'm more concerned about the platform differences that we would have to
note in the documentation:

  "To have multple hooks, do X and Y and make sure the standard hook
   file is not executable. Oh, and by the way, if you are on Windows,
   you have to remove the file to make it not executable."

Let's not go there.

-- Hannes
diff mbox series

Patch

diff --git a/builtin/commit.c b/builtin/commit.c
index f17537474a..e7cf6b16ba 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -666,6 +666,7 @@  static int prepare_to_commit(const char *index_file, const char *prefix,
 	struct strbuf sb = STRBUF_INIT;
 	const char *hook_arg1 = NULL;
 	const char *hook_arg2 = NULL;
+	struct string_list *hooks;
 	int clean_message_contents = (cleanup_mode != COMMIT_MSG_CLEANUP_NONE);
 	int old_display_comment_prefix;
 
@@ -943,13 +944,15 @@  static int prepare_to_commit(const char *index_file, const char *prefix,
 		return 0;
 	}
 
-	if (!no_verify && find_hook("pre-commit")) {
+	hooks = find_hooks("pre-commit");
+	if (!no_verify && hooks) {
 		/*
 		 * Re-read the index as pre-commit hook could have updated it,
 		 * and write it out as a tree.  We must do this before we invoke
 		 * the editor and after we invoke run_status above.
 		 */
 		discard_cache();
+		free_hooks(hooks);
 	}
 	read_cache_from(index_file);
 
diff --git a/run-command.c b/run-command.c
index 3449db319b..669af5ebc7 100644
--- a/run-command.c
+++ b/run-command.c
@@ -1308,58 +1308,137 @@  int async_with_fork(void)
 #endif
 }
 
+static int has_hook(struct strbuf *path, int strip)
+{
+	if (access(path->buf, X_OK) < 0) {
+		int err = errno;
+
+		if (strip) {
+#ifdef STRIP_EXTENSION
+			strbuf_addstr(path, STRIP_EXTENSION);
+			if (access(path->buf, X_OK) >= 0)
+				return 1;
+			if (errno == EACCES)
+				err = errno;
+#endif
+		}
+
+		if (err == EACCES && advice_ignored_hook) {
+			static struct string_list advise_given = STRING_LIST_INIT_DUP;
+
+			if (!string_list_lookup(&advise_given, path->buf)) {
+				string_list_insert(&advise_given, path->buf);
+				advise(_("The '%s' hook was ignored because "
+					 "it's not set as executable.\n"
+					 "You can disable this warning with "
+					 "`git config advice.ignoredHook false`."),
+				       path->buf);
+			}
+		}
+		return 0;
+	}
+	return 1;
+}
+
 const char *find_hook(const char *name)
 {
 	static struct strbuf path = STRBUF_INIT;
 
 	strbuf_reset(&path);
 	strbuf_git_path(&path, "hooks/%s", name);
-	if (access(path.buf, X_OK) < 0) {
-		int err = errno;
+	if (has_hook(&path, 1)) {
+		return path.buf;
+	}
+	return NULL;
+}
 
-#ifdef STRIP_EXTENSION
-		strbuf_addstr(&path, STRIP_EXTENSION);
-		if (access(path.buf, X_OK) >= 0)
-			return path.buf;
-		if (errno == EACCES)
-			err = errno;
-#endif
+struct string_list *find_hooks(const char *name)
+{
+	struct string_list *list = xmalloc(sizeof(*list));
+	struct strbuf path = STRBUF_INIT;
+	DIR *d;
+	struct dirent *de;
 
-		if (err == EACCES && advice_ignored_hook) {
-			static struct string_list advise_given = STRING_LIST_INIT_DUP;
+	string_list_init(list, 1);
 
-			if (!string_list_lookup(&advise_given, name)) {
-				string_list_insert(&advise_given, name);
-				advise(_("The '%s' hook was ignored because "
-					 "it's not set as executable.\n"
-					 "You can disable this warning with "
-					 "`git config advice.ignoredHook false`."),
-				       path.buf);
-			}
-		}
+	/*
+	 * We look for a single hook. If present, return it, and skip the
+	 * individual directories.
+	 */
+	strbuf_git_path(&path, "hooks/%s", name);
+	if (has_hook(&path, 1)) {
+		string_list_append(list, path.buf);
+		return list;
+	}
+
+	strbuf_reset(&path);
+	strbuf_git_path(&path, "hooks/%s.d", name);
+	d = opendir(path.buf);
+	if (!d) {
+		string_list_clear(list, 0);
 		return NULL;
 	}
-	return path.buf;
+	while ((de = readdir(d))) {
+		if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, ".."))
+			continue;
+		strbuf_reset(&path);
+		strbuf_git_path(&path, "hooks/%s.d/%s", name, de->d_name);
+		if (has_hook(&path, 0))
+			string_list_append(list, path.buf);
+	}
+	closedir(d);
+	strbuf_reset(&path);
+	if (!list->nr) {
+		free_hooks(list);
+		return NULL;
+	}
+
+	string_list_sort(list);
+	return list;
+}
+
+void free_hooks(struct string_list *hooks)
+{
+	string_list_clear(hooks, 0);
+	free(hooks);
 }
 
 int run_hook_ve(const char *const *env, const char *name, va_list args)
 {
-	struct child_process hook = CHILD_PROCESS_INIT;
+	struct string_list *hooks;
+	struct string_list arglist = STRING_LIST_INIT_NODUP;
 	const char *p;
+	struct string_list_item *q;
+	int ret = 0;
 
-	p = find_hook(name);
-	if (!p)
+	hooks = find_hooks(name);
+	if (!hooks)
 		return 0;
 
-	argv_array_push(&hook.args, p);
 	while ((p = va_arg(args, const char *)))
-		argv_array_push(&hook.args, p);
-	hook.env = env;
-	hook.no_stdin = 1;
-	hook.stdout_to_stderr = 1;
-	hook.trace2_hook_name = name;
+		string_list_append(&arglist, p);
 
-	return run_command(&hook);
+	for_each_string_list_item(q, hooks) {
+		struct child_process hook = CHILD_PROCESS_INIT;
+		struct string_list_item *arg;
+
+		argv_array_push(&hook.args, q->string);
+		for_each_string_list_item(arg, &arglist) {
+			argv_array_push(&hook.args, arg->string);
+		}
+
+		hook.env = env;
+		hook.no_stdin = 1;
+		hook.stdout_to_stderr = 1;
+		hook.trace2_hook_name = name;
+
+		ret = run_command(&hook);
+		if (ret)
+			break;
+	}
+	string_list_clear(&arglist, 0);
+	free_hooks(hooks);
+	return ret;
 }
 
 int run_hook_le(const char *const *env, const char *name, ...)
diff --git a/run-command.h b/run-command.h
index a6950691c0..7266dc2969 100644
--- a/run-command.h
+++ b/run-command.h
@@ -68,6 +68,13 @@  int run_command(struct child_process *);
  * overwritten by further calls to find_hook and run_hook_*.
  */
 extern const char *find_hook(const char *name);
+/*
+ * Returns the paths to all hook files, or NULL if all hooks are missing or
+ * disabled.
+ */
+struct string_list *find_hooks(const char *name);
+/* Frees the result of find_hooks. */
+void free_hooks(struct string_list *hooks);
 LAST_ARG_MUST_BE_NULL
 extern int run_hook_le(const char *const *env, const char *name, ...);
 extern int run_hook_ve(const char *const *env, const char *name, va_list args);
diff --git a/t/lib-hooks.sh b/t/lib-hooks.sh
new file mode 100644
index 0000000000..91b3acaba2
--- /dev/null
+++ b/t/lib-hooks.sh
@@ -0,0 +1,156 @@ 
+create_multihooks () {
+	mkdir -p "$MULTIHOOK_DIR"
+	for i in "a $1" "b $2" "c $3"
+	do
+		echo "$i" | (while read script ex
+		do
+			mkdir -p "$MULTIHOOK_DIR"
+			write_script "$MULTIHOOK_DIR/$script" <<-EOF
+			mkdir -p "$OUTPUTDIR"
+			touch "$OUTPUTDIR/$script"
+			exit $ex
+			EOF
+		done)
+	done
+}
+
+# Run the multiple hook tests.
+# Usage: test_multiple_hooks [--ignore-exit-status] HOOK COMMAND [SKIP-COMMAND]
+# HOOK:  the name of the hook to test
+# COMMAND: command to test the hook for; takes a single argument indicating test
+# name.
+# SKIP-COMMAND: like $1, except the hook should be skipped.
+# --ignore-exit-status: the command does not fail if the exit status from the
+# hook is nonzero.
+test_multiple_hooks () {
+	local must_fail cmd skip_cmd hook
+	if test "$1" = "--ignore-exit-status"
+	then
+		shift
+	else
+		must_fail="test_must_fail"
+	fi
+	hook="$1"
+	cmd="$2"
+	skip_cmd="$3"
+
+	HOOKDIR="$(git rev-parse --absolute-git-dir)/hooks"
+	OUTPUTDIR="$(git rev-parse --absolute-git-dir)/../hook-output"
+	HOOK="$HOOKDIR/$hook"
+	MULTIHOOK_DIR="$HOOKDIR/$hook.d"
+	rm -f "$HOOK" "$MULTIHOOK_DIR" "$OUTPUTDIR"
+
+	test_expect_success "$hook: with no hook" '
+		$cmd foo
+	'
+
+	if test -n "$skip_cmd"
+	then
+		test_expect_success "$hook: skipped hook with no hook" '
+			$skip_cmd bar
+		'
+	fi
+
+	test_expect_success 'setup' '
+		mkdir -p "$HOOKDIR" &&
+		write_script "$HOOK" <<-EOF
+		mkdir -p "$OUTPUTDIR"
+		touch "$OUTPUTDIR/simple"
+		exit 0
+		EOF
+	'
+
+	test_expect_success "$hook: with succeeding hook" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		$cmd more &&
+		test -f "$OUTPUTDIR/simple"
+	'
+
+	if test -n "$skip_cmd"
+	then
+		test_expect_success "$hook: skipped but succeeding hook" '
+			test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+			$skip_cmd even-more &&
+			! test -f "$OUTPUTDIR/simple"
+		'
+	fi
+
+	test_expect_success "$hook: with both simple and multiple hooks, simple success" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		create_multihooks 0 1 0 &&
+		$cmd yet-more &&
+		test -f "$OUTPUTDIR/simple" &&
+		! test -f "$OUTPUTDIR/a" &&
+		! test -f "$OUTPUTDIR/b" &&
+		! test -f "$OUTPUTDIR/c"
+	'
+
+	test_expect_success 'setup' '
+		rm -fr "$MULTIHOOK_DIR" &&
+
+		# now a hook that fails
+		write_script "$HOOK" <<-EOF
+		mkdir -p "$OUTPUTDIR"
+		touch "$OUTPUTDIR/simple"
+		exit 1
+		EOF
+	'
+
+	test_expect_success "$hook: with failing hook" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		$must_fail $cmd another &&
+		test -f "$OUTPUTDIR/simple"
+	'
+
+	if test -n "$skip_cmd"
+	then
+		test_expect_success "$hook: skipped but failing hook" '
+			test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+			$skip_cmd stuff &&
+			! test -f "$OUTPUTDIR/simple"
+		'
+	fi
+
+	test_expect_success "$hook: with both simple and multiple hooks, simple failure" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		create_multihooks 0 1 0 &&
+		$must_fail $cmd more-stuff &&
+		test -f "$OUTPUTDIR/simple" &&
+		! test -f "$OUTPUTDIR/a" &&
+		! test -f "$OUTPUTDIR/b" &&
+		! test -f "$OUTPUTDIR/c"
+	'
+
+	test_expect_success "$hook: multiple hooks, all successful" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		rm -f "$HOOK" &&
+		create_multihooks 0 0 0 &&
+		$cmd content &&
+		test -f "$OUTPUTDIR/a" &&
+		test -f "$OUTPUTDIR/b" &&
+		test -f "$OUTPUTDIR/c"
+	'
+
+	test_expect_success "$hook: hooks after first failure not executed" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		create_multihooks 0 1 0 &&
+		$must_fail $cmd more-content &&
+		test -f "$OUTPUTDIR/a" &&
+		test -f "$OUTPUTDIR/b" &&
+		! test -f "$OUTPUTDIR/c"
+	'
+
+	test_expect_success POSIXPERM "$hook: non-executable hook not executed" '
+		test_when_finished "rm -fr \"$OUTPUTDIR\"" &&
+		create_multihooks 0 1 0 &&
+		chmod -x "$MULTIHOOK_DIR/b" &&
+		$cmd things &&
+		test -f "$OUTPUTDIR/a" &&
+		! test -f "$OUTPUTDIR/b" &&
+		test -f "$OUTPUTDIR/c"
+	'
+
+	test_expect_success 'cleanup' '
+		rm -fr "$MULTIHOOK_DIR"
+	'
+}
diff --git a/t/t7503-pre-commit-hook.sh b/t/t7503-pre-commit-hook.sh
index 984889b39d..d63d059e04 100755
--- a/t/t7503-pre-commit-hook.sh
+++ b/t/t7503-pre-commit-hook.sh
@@ -3,6 +3,7 @@ 
 test_description='pre-commit hook'
 
 . ./test-lib.sh
+. "$TEST_DIRECTORY/lib-hooks.sh"
 
 test_expect_success 'with no hook' '
 
@@ -136,4 +137,18 @@  test_expect_success 'check the author in hook' '
 	git show -s
 '
 
+commit_command () {
+	echo "$1" >>file &&
+	git add file &&
+	git commit -m "$1"
+}
+
+commit_no_verify_command () {
+	echo "$1" >>file &&
+	git add file &&
+	git commit --no-verify -m "$1"
+}
+
+test_multiple_hooks pre-commit commit_command commit_no_verify_command
+
 test_done