Web lists-archives.com

Re: [PATCH v2 1/7] run-command: add preliminary support for multiple hooks




Hi brian,

On Tue, 14 May 2019, brian m. carlson wrote:

> diff --git a/builtin/commit.c b/builtin/commit.c
> index 833ecb316a..29bf80e0d1 100644
> --- a/builtin/commit.c
> +++ b/builtin/commit.c
> @@ -943,7 +943,7 @@ static int prepare_to_commit(const char *index_file, const char *prefix,
>  		return 0;
>  	}
>
> -	if (!no_verify && find_hook("pre-commit")) {
> +	if (!no_verify && find_hooks("pre-commit", NULL)) {

Hmm. This makes me concerned, as `find_hook()` essentially boiled down to
a semi-fast `stat()` check, but `find_hooks()` needs to really look,
right?

It might make sense to somehow keep the list of discovered and executed
hooks, as we already have a call to `run_commit_hook()` to execute the
`pre-commit` hook at the beginning of this function.

Speaking of which... Shouldn't that `run_commit_hook()` call be adjusted
at the same time, or else we have an inconsistent situation?

>  		/*
>  		 * 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
> diff --git a/run-command.c b/run-command.c
> index 3449db319b..eb075ac86b 100644
> --- a/run-command.c
> +++ b/run-command.c
> @@ -1308,53 +1308,143 @@ int async_with_fork(void)
>  #endif
>  }
>
> +/*
> + * Return 1 if a hook exists at path (which may be modified) using access(2)
> + * with check (which should be F_OK or X_OK), 0 otherwise. If strip is true,
> + * additionally consider the same filename but with STRIP_EXTENSION added.
> + * If check is X_OK, warn if the hook exists but is not executable.
> + */
> +static int has_hook(struct strbuf *path, int strip, int check)
> +{
> +	if (access(path->buf, check) < 0) {
> +		int err = errno;
> +
> +		if (strip) {
> +#ifdef STRIP_EXTENSION
> +			strbuf_addstr(path, STRIP_EXTENSION);
> +			if (access(path->buf, check) >= 0)
> +				return 1;
> +			if (errno == EACCES)
> +				err = errno;
> +#endif
> +		}

How about simply guarding the entire `if()`? It is a bit unusual to guard
*only* the inside block ;-)

> +
> +		if (err == EACCES && advice_ignored_hook) {

Didn't you want to test for `X_OK` here, too? The code comment above the
function seems to suggest that.

> +			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;

Wouldn't it make sense to do this very early? Something like

	if (!access(path->buf, check))
		return 1;

first thing in the function, that that part is already out of the way and
we don't have to indent so much.

> +}
> +
>  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;
> -
> -#ifdef STRIP_EXTENSION
> -		strbuf_addstr(&path, STRIP_EXTENSION);
> -		if (access(path.buf, X_OK) >= 0)
> -			return path.buf;
> -		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, 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);
> -			}
> -		}
> -		return NULL;

So that's where this comes from ;-)

> +	if (has_hook(&path, 1, X_OK)) {
> +		return path.buf;
>  	}
> -	return path.buf;
> +	return NULL;
>  }
>
> -int run_hook_ve(const char *const *env, const char *name, va_list args)
> +int find_hooks(const char *name, struct string_list *list)
>  {
> -	struct child_process hook = CHILD_PROCESS_INIT;
> -	const char *p;
> +	struct strbuf path = STRBUF_INIT;
> +	DIR *d;
> +	struct dirent *de;
>
> -	p = find_hook(name);
> -	if (!p)
> +	/*
> +	 * 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, X_OK)) {
> +		if (list)
> +			string_list_append(list, path.buf);
> +		return 1;
> +	}
> +
> +	if (has_hook(&path, 1, F_OK))
>  		return 0;
>
> -	argv_array_push(&hook.args, p);
> -	while ((p = va_arg(args, const char *)))
> -		argv_array_push(&hook.args, p);
> -	hook.env = env;
> +	strbuf_reset(&path);
> +	strbuf_git_path(&path, "hooks/%s.d", name);
> +	d = opendir(path.buf);
> +	if (!d) {
> +		if (list)
> +			string_list_clear(list, 0);
> +		return 0;
> +	}
> +	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, X_OK)) {
> +			if (list)
> +				string_list_append(list, path.buf);
> +			else
> +				return 1;
> +		}
> +	}
> +	closedir(d);
> +	strbuf_reset(&path);
> +	if (!list->nr) {
> +		return 0;
> +	}
> +
> +	string_list_sort(list);
> +	return 1;
> +}
> +
> +int for_each_hook(const char *name,
> +		  int (*handler)(const char *name, const char *path, void *),
> +		  void *data)
> +{
> +	struct string_list paths = STRING_LIST_INIT_DUP;
> +	int i, ret = 0;
> +
> +	find_hooks(name, &paths);
> +	for (i = 0; i < paths.nr; i++) {
> +		const char *p = paths.items[i].string;
> +
> +		ret = handler(name, p, data);
> +		if (ret)
> +			break;
> +	}
> +
> +	string_list_clear(&paths, 0);
> +	return ret;
> +}
> +
> +struct hook_data {
> +	const char *const *env;
> +	struct string_list *args;
> +};
> +
> +static int do_run_hook_ve(const char *name, const char *path, void *cb)
> +{
> +	struct hook_data *data = cb;
> +	struct child_process hook = CHILD_PROCESS_INIT;
> +	struct string_list_item *arg;
> +
> +	argv_array_push(&hook.args, path);
> +	for_each_string_list_item(arg, data->args) {
> +		argv_array_push(&hook.args, arg->string);
> +	}
> +
> +	hook.env = data->env;
>  	hook.no_stdin = 1;
>  	hook.stdout_to_stderr = 1;
>  	hook.trace2_hook_name = name;
> @@ -1362,6 +1452,21 @@ int run_hook_ve(const char *const *env, const char *name, va_list args)
>  	return run_command(&hook);
>  }
>
> +int run_hook_ve(const char *const *env, const char *name, va_list args)
> +{
> +	struct string_list arglist = STRING_LIST_INIT_DUP;
> +	struct hook_data data = {env, &arglist};
> +	const char *p;
> +	int ret = 0;
> +
> +	while ((p = va_arg(args, const char *)))
> +		string_list_append(&arglist, p);
> +
> +	ret = for_each_hook(name, do_run_hook_ve, &data);
> +	string_list_clear(&arglist, 0);
> +	return ret;
> +}
> +
>  int run_hook_le(const char *const *env, const char *name, ...)
>  {
>  	va_list args;
> diff --git a/run-command.h b/run-command.h
> index a6950691c0..1b3677fcac 100644
> --- a/run-command.h
> +++ b/run-command.h
> @@ -4,6 +4,7 @@
>  #include "thread-utils.h"
>
>  #include "argv-array.h"
> +#include "string-list.h"
>
>  struct child_process {
>  	const char **argv;
> @@ -68,6 +69,20 @@ 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.

Left-over comment?

> + * Returns 1 if there are hooks; 0 otherwise. If hooks is not NULL, stores the
> + * names of the hooks into them in the order they should be executed.
> + */
> +int find_hooks(const char *name, struct string_list *hooks);
> +/*
> + * Invokes the handler function once for each hook. Returns 0 if all hooks were
> + * successful, or the exit status of the first failing hook.
> + */
> +int for_each_hook(const char *name,
> +		  int (*handler)(const char *name, const char *path, void *),
> +		  void *data);

My gut says that it would make sense for `struct repository *` to sprout a
hashmap for hook lookup that would be populated by demand, and allowed
things like

	hash_hook(r, "pre-commit")

I can't follow up with code, though, as I'm off for the day!

Ciao,
Dscho

>  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..721250aea0
> --- /dev/null
> +++ b/t/lib-hooks.sh
> @@ -0,0 +1,172 @@
> +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 POSIXPERM "$hook: multiple hooks not executed if simple hook present" '
> +		test_when_finished "rm -fr \"$OUTPUTDIR\" && rm -f \"$HOOK\"" &&
> +		write_script "$HOOK" <<-EOF &&
> +		mkdir -p "$OUTPUTDIR"
> +		touch "$OUTPUTDIR/simple"
> +		exit 0
> +		EOF
> +		create_multihooks 0 1 0 &&
> +		chmod -x "$HOOK" &&
> +		$cmd other-things &&
> +		! test -f "$OUTPUTDIR/simple" &&
> +		! 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
>