#!/bin/sh

test_description='git-hook command and config-managed multihooks'

. ./test-lib.sh
. "$TEST_DIRECTORY"/lib-terminal.sh

setup_hooks () {
	test_config hook.ghi.command "/path/ghi" &&
	test_config hook.ghi.event pre-commit --add &&
	test_config hook.ghi.event test-hook --add &&
	test_config_global hook.def.command "/path/def" &&
	test_config_global hook.def.event pre-commit --add
}

setup_hookdir () {
	mkdir -p .git/hooks &&
	write_script .git/hooks/pre-commit <<-EOF &&
	echo \"Legacy Hook\"
	EOF
	test_when_finished rm -rf .git/hooks
}

# write_sentinel_hook <path> [sentinel]
#
# Writes a hook that marks itself as started, sleeps for a few seconds, then
# marks itself done. The sleep must be long enough that sentinel_detector can
# observe <sentinel>.started before <sentinel>.done appears when both hooks
# run concurrently in parallel mode.
write_sentinel_hook () {
	sentinel="${2:-sentinel}"
	write_script "$1" <<-EOF
	touch ${sentinel}.started &&
	sleep 2 &&
	touch ${sentinel}.done
	EOF
}

# sentinel_detector <sentinel> <output>
#
# Returns a shell command string suitable for use as hook.<name>.command.
# The detector must be registered after the sentinel:
# 1. In serial mode, the sentinel has completed (and <sentinel>.done exists)
#    before the detector starts.
# 2. In parallel mode, both run concurrently so <sentinel>.done has not appeared
#    yet and the detector just sees <sentinel>.started.
#
# At start, poll until <sentinel>.started exists to absorb startup jitter, then
# write to <output>:
# 1. 'serial'   if <sentinel>.done exists (sentinel finished before we started),
# 2. 'parallel' if only <sentinel>.started exists (sentinel still running),
# 3. 'timeout'  if <sentinel>.started never appeared.
#
# The command ends with ':' so when git appends "$@" for hooks that receive
# positional arguments (e.g. pre-push), the result ': "$@"' is valid shell
# rather than a syntax error 'fi "$@"'.
sentinel_detector () {
	cat <<-EOF
	i=0
	while ! test -f ${1}.started && test \$i -lt 10; do
	    sleep 1
	    i=\$((i+1))
	done
	if test -f ${1}.done; then
	    echo serial >${2}
	elif test -f ${1}.started; then
	    echo parallel >${2}
	else
	    echo timeout >${2}
	fi
	:
	EOF
}

test_expect_success 'git hook usage' '
	test_expect_code 129 git hook &&
	test_expect_code 129 git hook run &&
	test_expect_code 129 git hook run -h &&
	test_expect_code 129 git hook run --unknown 2>err &&
	test_expect_code 129 git hook list &&
	test_expect_code 129 git hook list -h &&
	grep "unknown option" err
'

test_expect_success 'git hook list: unknown hook name is rejected' '
	test_must_fail git hook list prereceive 2>err &&
	test_grep "unknown hook event" err
'

test_expect_success 'git hook run: unknown hook name is rejected' '
	test_must_fail git hook run prereceive 2>err &&
	test_grep "unknown hook event" err
'

test_expect_success 'git hook list: known hook name is accepted' '
	test_must_fail git hook list pre-receive 2>err &&
	test_grep ! "unknown hook event" err
'

test_expect_success 'git hook run: known hook name is accepted' '
	git hook run --ignore-missing pre-receive 2>err &&
	test_grep ! "unknown hook event" err
'

test_expect_success 'git hook run: --allow-unknown-hook-name overrides rejection' '
	git hook run --allow-unknown-hook-name --ignore-missing custom-hook 2>err &&
	test_grep ! "unknown hook event" err
'

test_expect_success 'git hook list: --allow-unknown-hook-name overrides rejection' '
	test_must_fail git hook list --allow-unknown-hook-name custom-hook 2>err &&
	test_grep ! "unknown hook event" err
'

test_expect_success 'git hook list: nonexistent hook' '
	cat >stderr.expect <<-\EOF &&
	warning: no hooks found for event '\''test-hook'\''
	EOF
	test_expect_code 1 git hook list --allow-unknown-hook-name test-hook 2>stderr.actual &&
	test_cmp stderr.expect stderr.actual
'

test_expect_success 'git hook list: traditional hook from hookdir' '
	test_hook test-hook <<-EOF &&
	echo Test hook
	EOF

	cat >expect <<-\EOF &&
	hook from hookdir
	EOF
	git hook list --allow-unknown-hook-name test-hook >actual &&
	test_cmp expect actual
'

test_expect_success 'git hook list: configured hook' '
	test_config hook.myhook.command "echo Hello" &&
	test_config hook.myhook.event test-hook --add &&

	echo "myhook" >expect &&
	git hook list --allow-unknown-hook-name test-hook >actual &&
	test_cmp expect actual
'

test_expect_success 'git hook list: -z shows NUL-terminated output' '
	test_hook test-hook <<-EOF &&
	echo Test hook
	EOF
	test_config hook.myhook.command "echo Hello" &&
	test_config hook.myhook.event test-hook --add &&

	printf "myhookQhook from hookdirQ" >expect &&
	git hook list --allow-unknown-hook-name -z test-hook >actual.raw &&
	nul_to_q <actual.raw >actual &&
	test_cmp expect actual
'

test_expect_success 'git hook run: nonexistent hook' '
	cat >stderr.expect <<-\EOF &&
	error: cannot find a hook named test-hook
	EOF
	test_expect_code 1 git hook run --allow-unknown-hook-name test-hook 2>stderr.actual &&
	test_cmp stderr.expect stderr.actual
'

test_expect_success 'git hook run: nonexistent hook with --ignore-missing' '
	git hook run --allow-unknown-hook-name --ignore-missing does-not-exist 2>stderr.actual &&
	test_must_be_empty stderr.actual
'

test_expect_success 'git hook run: basic' '
	test_hook test-hook <<-EOF &&
	echo Test hook
	EOF

	cat >expect <<-\EOF &&
	Test hook
	EOF
	git hook run --allow-unknown-hook-name test-hook 2>actual &&
	test_cmp expect actual
'

test_expect_success 'git hook run: stdout and stderr both write to our stderr' '
	test_hook test-hook <<-EOF &&
	echo >&1 Will end up on stderr
	echo >&2 Will end up on stderr
	EOF

	cat >stderr.expect <<-\EOF &&
	Will end up on stderr
	Will end up on stderr
	EOF
	git hook run --allow-unknown-hook-name test-hook >stdout.actual 2>stderr.actual &&
	test_cmp stderr.expect stderr.actual &&
	test_must_be_empty stdout.actual
'

for code in 1 2 128 129
do
	test_expect_success "git hook run: exit code $code is passed along" '
		test_hook test-hook <<-EOF &&
		exit $code
		EOF

		test_expect_code $code git hook run --allow-unknown-hook-name test-hook
	'
done

test_expect_success 'git hook run arg u ments without -- is not allowed' '
	test_expect_code 129 git hook run --allow-unknown-hook-name test-hook arg u ments
'

test_expect_success 'git hook run -- pass arguments' '
	test_hook test-hook <<-\EOF &&
	echo $1
	echo $2
	EOF

	cat >expect <<-EOF &&
	arg
	u ments
	EOF

	git hook run --allow-unknown-hook-name test-hook -- arg "u ments" 2>actual &&
	test_cmp expect actual
'

test_expect_success 'git hook run: out-of-repo runs execute global hooks' '
	test_config_global hook.global-hook.event test-hook --add &&
	test_config_global hook.global-hook.command "echo no repo no problems" --add &&

	echo "global-hook" >expect &&
	nongit git hook list --allow-unknown-hook-name test-hook >actual &&
	test_cmp expect actual &&

	echo "no repo no problems" >expect &&

	nongit git hook run --allow-unknown-hook-name test-hook 2>actual &&
	test_cmp expect actual
'

test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
	mkdir my-hooks &&
	write_script my-hooks/test-hook <<-\EOF &&
	echo Hook ran $1
	EOF

	cat >expect <<-\EOF &&
	Test hook
	Hook ran one
	Hook ran two
	Hook ran three
	Hook ran four
	EOF

	test_hook test-hook <<-EOF &&
	echo Test hook
	EOF

	# Test various ways of specifying the path. See also
	# t1350-config-hooks-path.sh
	>actual &&
	git hook run --allow-unknown-hook-name test-hook -- ignored 2>>actual &&
	git -c core.hooksPath=my-hooks hook run --allow-unknown-hook-name test-hook -- one 2>>actual &&
	git -c core.hooksPath=my-hooks/ hook run --allow-unknown-hook-name test-hook -- two 2>>actual &&
	git -c core.hooksPath="$PWD/my-hooks" hook run --allow-unknown-hook-name test-hook -- three 2>>actual &&
	git -c core.hooksPath="$PWD/my-hooks/" hook run --allow-unknown-hook-name test-hook -- four 2>>actual &&
	test_cmp expect actual
'

test_hook_tty () {
	expect_tty=$1
	shift

	if test "$expect_tty" != "no_tty"; then
		cat >expect <<-\EOF
		STDOUT TTY
		STDERR TTY
		EOF
	else
		cat >expect <<-\EOF
		STDOUT NO TTY
		STDERR NO TTY
		EOF
	fi

	test_when_finished "rm -rf repo" &&
	git init repo &&

	test_commit -C repo A &&
	test_commit -C repo B &&
	git -C repo reset --soft HEAD^ &&

	test_hook -C repo pre-commit <<-EOF &&
	test -t 1 && echo STDOUT TTY >>actual || echo STDOUT NO TTY >>actual &&
	test -t 2 && echo STDERR TTY >>actual || echo STDERR NO TTY >>actual
	EOF

	test_terminal git -C repo "$@" &&
	test_cmp expect repo/actual
}

test_expect_success TTY 'git hook run -j1: stdout and stderr are connected to a TTY' '
	# hooks running sequentially (-j1) are always connected to the tty for
	# optimum real-time performance.
	test_hook_tty tty hook run -j1 pre-commit
'

test_expect_success TTY 'git hook run -jN: stdout and stderr are not connected to a TTY' '
	# Hooks are not connected to the tty when run in parallel, instead they
	# output to a pipe through which run-command collects and de-interlaces
	# their outputs, which then gets passed either to the tty or a sideband.
	test_hook_tty no_tty hook run -j2 pre-commit
'

test_expect_success TTY 'git commit: stdout and stderr are connected to a TTY' '
	test_hook_tty tty commit -m"B.new"
'

test_expect_success 'git hook list orders by config order' '
	setup_hooks &&

	cat >expected <<-\EOF &&
	def
	ghi
	EOF

	git hook list pre-commit >actual &&
	test_cmp expected actual
'

test_expect_success 'git hook list reorders on duplicate event declarations' '
	setup_hooks &&

	# 'def' is usually configured globally; move it to the end by
	# configuring it locally.
	test_config hook.def.event "pre-commit" --add &&

	cat >expected <<-\EOF &&
	ghi
	def
	EOF

	git hook list pre-commit >actual &&
	test_cmp expected actual
'

test_expect_success 'git hook list: empty event value resets events' '
	setup_hooks &&

	# ghi is configured for pre-commit; reset it with an empty value
	test_config hook.ghi.event "" --add &&

	# only def should remain for pre-commit
	echo "def" >expected &&
	git hook list pre-commit >actual &&
	test_cmp expected actual
'

test_expect_success 'hook can be configured for multiple events' '
	setup_hooks &&

	# 'ghi' should be included in both 'pre-commit' and 'test-hook'
	git hook list pre-commit >actual &&
	grep "ghi" actual &&
	git hook list --allow-unknown-hook-name test-hook >actual &&
	grep "ghi" actual
'

test_expect_success 'git hook list shows hooks from the hookdir' '
	setup_hookdir &&

	cat >expected <<-\EOF &&
	hook from hookdir
	EOF

	git hook list pre-commit >actual &&
	test_cmp expected actual
'

test_expect_success 'inline hook definitions execute oneliners' '
	test_config hook.oneliner.event "pre-commit" &&
	test_config hook.oneliner.command "echo \"Hello World\"" &&

	echo "Hello World" >expected &&

	# hooks are run with stdout_to_stderr = 1
	git hook run pre-commit 2>actual &&
	test_cmp expected actual
'

test_expect_success 'inline hook definitions resolve paths' '
	write_script sample-hook.sh <<-\EOF &&
	echo \"Sample Hook\"
	EOF

	test_when_finished "rm sample-hook.sh" &&

	test_config hook.sample-hook.event pre-commit &&
	test_config hook.sample-hook.command "\"$(pwd)/sample-hook.sh\"" &&

	echo \"Sample Hook\" >expected &&

	# hooks are run with stdout_to_stderr = 1
	git hook run pre-commit 2>actual &&
	test_cmp expected actual
'

test_expect_success 'hookdir hook included in git hook run' '
	setup_hookdir &&

	echo \"Legacy Hook\" >expected &&

	# hooks are run with stdout_to_stderr = 1
	git hook run pre-commit 2>actual &&
	test_cmp expected actual
'

test_expect_success 'stdin to multiple hooks' '
	test_config hook.stdin-a.event "test-hook" &&
	test_config hook.stdin-a.command "xargs -P1 -I% echo a%" &&
	test_config hook.stdin-b.event "test-hook" &&
	test_config hook.stdin-b.command "xargs -P1 -I% echo b%" &&

	cat >input <<-\EOF &&
	1
	2
	3
	EOF

	cat >expected <<-\EOF &&
	a1
	a2
	a3
	b1
	b2
	b3
	EOF

	git hook run --allow-unknown-hook-name --to-stdin=input test-hook 2>actual &&
	test_cmp expected actual
'

test_expect_success 'rejects hooks with no commands configured' '
	test_config hook.broken.event "test-hook" &&
	test_must_fail git hook list --allow-unknown-hook-name test-hook 2>actual &&
	test_grep "hook.broken.command" actual &&
	test_must_fail git hook run --allow-unknown-hook-name test-hook 2>actual &&
	test_grep "hook.broken.command" actual
'

test_expect_success 'disabled hook is not run' '
	test_config hook.skipped.event "test-hook" &&
	test_config hook.skipped.command "echo \"Should not run\"" &&
	test_config hook.skipped.enabled false &&

	git hook run --allow-unknown-hook-name --ignore-missing test-hook 2>actual &&
	test_must_be_empty actual
'

test_expect_success 'disabled hook with no command warns' '
	test_config hook.nocommand.event "pre-commit" &&
	test_config hook.nocommand.enabled false &&

	git hook list pre-commit 2>actual &&
	test_grep "disabled hook.*nocommand.*no command configured" actual
'

test_expect_success 'disabled hook appears as disabled in git hook list' '
	test_config hook.active.event "pre-commit" &&
	test_config hook.active.command "echo active" &&
	test_config hook.inactive.event "pre-commit" &&
	test_config hook.inactive.command "echo inactive" &&
	test_config hook.inactive.enabled false &&

	git hook list pre-commit >actual &&
	test_grep "^active$" actual &&
	test_grep "^disabled	inactive$" actual
'

test_expect_success 'disabled hook shows scope with --show-scope' '
	test_config hook.myhook.event "pre-commit" &&
	test_config hook.myhook.command "echo hi" &&
	test_config hook.myhook.enabled false &&

	git hook list --show-scope pre-commit >actual &&
	test_grep "^local	disabled	myhook$" actual
'

test_expect_success 'disabled configured hook is not reported as existing by hook_exists' '
	test_when_finished "rm -f git-bugreport-hook-exists-test.txt" &&
	test_config hook.linter.event "pre-commit" &&
	test_config hook.linter.command "echo lint" &&
	test_config hook.linter.enabled false &&

	git bugreport -s hook-exists-test &&
	test_grep ! "pre-commit" git-bugreport-hook-exists-test.txt
'

test_expect_success 'globally disabled hook can be re-enabled locally' '
	test_config_global hook.global-hook.event "test-hook" &&
	test_config_global hook.global-hook.command "echo \"global-hook ran\"" &&
	test_config_global hook.global-hook.enabled false &&
	test_config hook.global-hook.enabled true &&

	echo "global-hook ran" >expected &&
	git hook run --allow-unknown-hook-name test-hook 2>actual &&
	test_cmp expected actual
'

test_expect_success 'configured hooks run before hookdir hook' '
	setup_hookdir &&
	test_config hook.first.event "pre-commit" &&
	test_config hook.first.command "echo first" &&
	test_config hook.second.event "pre-commit" &&
	test_config hook.second.command "echo second" &&

	cat >expected <<-\EOF &&
	first
	second
	hook from hookdir
	EOF

	git hook list pre-commit >actual &&
	test_cmp expected actual &&

	# "Legacy Hook" is the output of the hookdir pre-commit script
	# written by setup_hookdir() above.
	cat >expected <<-\EOF &&
	first
	second
	"Legacy Hook"
	EOF

	git hook run pre-commit 2>actual &&
	test_cmp expected actual
'

test_expect_success 'git hook list --show-scope shows config scope' '
	setup_hookdir &&
	test_config_global hook.global-hook.command "echo global" &&
	test_config_global hook.global-hook.event pre-commit --add &&
	test_config hook.local-hook.command "echo local" &&
	test_config hook.local-hook.event pre-commit --add &&

	cat >expected <<-\EOF &&
	global	global-hook
	local	local-hook
	hook from hookdir
	EOF
	git hook list --show-scope pre-commit >actual &&
	test_cmp expected actual &&

	# without --show-scope the scope must not appear
	git hook list pre-commit >actual &&
	test_grep ! "^global	" actual &&
	test_grep ! "^local	" actual
'

test_expect_success 'git hook run a hook with a bad shebang' '
	test_when_finished "rm -rf bad-hooks" &&
	mkdir bad-hooks &&
	write_script bad-hooks/test-hook "/bad/path/no/spaces" </dev/null &&

	test_expect_code 1 git \
		-c core.hooksPath=bad-hooks \
		hook run --allow-unknown-hook-name test-hook >out 2>err &&
	test_must_be_empty out &&

	# TODO: We should emit the same (or at least a more similar)
	# error on MINGW (essentially Git for Windows) and all other
	# platforms.. See the OS-specific code in start_command()
	grep -E "^(error|fatal): cannot (exec|spawn) .*bad-hooks/test-hook" err
'

test_expect_success 'stdin to hooks' '
	mkdir -p .git/hooks &&
	write_script .git/hooks/test-hook <<-\EOF &&
	echo BEGIN stdin
	cat
	echo END stdin
	EOF

	cat >expect <<-EOF &&
	BEGIN stdin
	hello
	END stdin
	EOF

	echo hello >input &&
	git hook run --allow-unknown-hook-name --to-stdin=input test-hook 2>actual &&
	test_cmp expect actual
'

check_stdout_separate_from_stderr () {
	for hook in "$@"
	do
		# Ensure hook's stdout is only in stdout, not stderr
		test_grep "Hook $hook stdout" stdout.actual || return 1
		test_grep ! "Hook $hook stdout" stderr.actual || return 1

		# Ensure hook's stderr is only in stderr, not stdout
		test_grep "Hook $hook stderr" stderr.actual || return 1
		test_grep ! "Hook $hook stderr" stdout.actual || return 1
	done
}

check_stdout_merged_to_stderr () {
	for hook in "$@"
	do
		# Ensure hook's stdout is only in stderr, not stdout
		test_grep "Hook $hook stdout" stderr.actual || return 1
		test_grep ! "Hook $hook stdout" stdout.actual || return 1

		# Ensure hook's stderr is only in stderr, not stdout
		test_grep "Hook $hook stderr" stderr.actual || return 1
		test_grep ! "Hook $hook stderr" stdout.actual || return 1
	done
}

setup_hooks () {
	for hook in "$@"
	do
		test_hook $hook <<-EOF
		echo >&1 Hook $hook stdout
		echo >&2 Hook $hook stderr
		EOF
	done
}

test_expect_success 'client hooks: pre-push expects separate stdout and stderr' '
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git init --bare remote &&
	git remote add origin remote &&
	test_commit A &&
	setup_hooks pre-push &&
	git push origin HEAD:main >stdout.actual 2>stderr.actual &&
	check_stdout_separate_from_stderr pre-push
'

test_expect_success 'client hooks: commit hooks expect stdout redirected to stderr' '
	hooks="pre-commit prepare-commit-msg \
		commit-msg post-commit \
		reference-transaction" &&
	setup_hooks $hooks &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git checkout -B main &&
	git checkout -b branch-a &&
	test_commit commit-on-branch-a &&
	git commit --allow-empty -m "Test" >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr $hooks
'

test_expect_success 'client hooks: checkout hooks expect stdout redirected to stderr' '
	setup_hooks post-checkout reference-transaction &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git checkout -b new-branch main >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr post-checkout reference-transaction
'

test_expect_success 'client hooks: merge hooks expect stdout redirected to stderr' '
	setup_hooks pre-merge-commit post-merge reference-transaction &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	test_commit new-branch-commit &&
	git merge --no-ff branch-a >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr pre-merge-commit post-merge reference-transaction
'

test_expect_success 'client hooks: post-rewrite hooks expect stdout redirected to stderr' '
	setup_hooks post-rewrite reference-transaction &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git commit --amend --allow-empty --no-edit >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr post-rewrite reference-transaction
'

test_expect_success 'client hooks: applypatch hooks expect stdout redirected to stderr' '
	setup_hooks applypatch-msg pre-applypatch post-applypatch &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git checkout -b branch-b main &&
	test_commit branch-b &&
	git format-patch -1 --stdout >patch &&
	git checkout -b branch-c main &&
	git am patch >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr applypatch-msg pre-applypatch post-applypatch
'

test_expect_success 'client hooks: rebase hooks expect stdout redirected to stderr' '
	setup_hooks pre-rebase &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git checkout -b branch-d main &&
	test_commit branch-d &&
	git checkout main &&
	test_commit diverge-main &&
	git checkout branch-d &&
	git rebase main >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr pre-rebase
'

test_expect_success 'client hooks: post-index-change expects stdout redirected to stderr' '
	setup_hooks post-index-change &&
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	oid=$(git hash-object -w --stdin </dev/null) &&
	git update-index --add --cacheinfo 100644 $oid new-file \
	    >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr post-index-change
'

test_expect_success 'server hooks expect stdout redirected to stderr' '
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git init --bare remote-server &&
	git remote add origin-server remote-server &&
	cd remote-server &&
	setup_hooks pre-receive update post-receive post-update &&
	cd .. &&
	git push origin-server HEAD:new-branch >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr pre-receive update post-receive post-update
'

test_expect_success 'server push-to-checkout hook expects stdout redirected to stderr' '
	test_when_finished "rm -f stdout.actual stderr.actual" &&
	git init server &&
	git -C server checkout -b main &&
	test_config -C server receive.denyCurrentBranch updateInstead &&
	git remote add origin-server-2 server &&
	cd server &&
	setup_hooks push-to-checkout &&
	cd .. &&
	git push origin-server-2 HEAD:main >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr push-to-checkout
'

test_expect_success 'parallel hook output is not interleaved' '
	test_when_finished "rm -rf .git/hooks" &&

	write_script .git/hooks/test-hook <<-EOF &&
	echo "Hook 1 Start"
	sleep 1
	echo "Hook 1 End"
	EOF

	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
		    "echo \"Hook 2 Start\"; sleep 2; echo \"Hook 2 End\"" &&
	test_config hook.hook-2.parallel true &&
	test_config hook.hook-3.event test-hook &&
	test_config hook.hook-3.command \
		    "echo \"Hook 3 Start\"; sleep 3; echo \"Hook 3 End\"" &&
	test_config hook.hook-3.parallel true &&

	git hook run --allow-unknown-hook-name -j3 test-hook >out 2>err.parallel &&

	# Verify Hook 1 output is grouped
	sed -n "/Hook 1 Start/,/Hook 1 End/p" err.parallel >hook1_out &&
	test_line_count = 2 hook1_out &&

	# Verify Hook 2 output is grouped
	sed -n "/Hook 2 Start/,/Hook 2 End/p" err.parallel >hook2_out &&
	test_line_count = 2 hook2_out &&

	# Verify Hook 3 output is grouped
	sed -n "/Hook 3 Start/,/Hook 3 End/p" err.parallel >hook3_out &&
	test_line_count = 2 hook3_out
'

test_expect_success 'git hook run -j1 runs hooks in series' '
	test_when_finished "rm -rf .git/hooks" &&

	test_config hook.series-1.event "test-hook" &&
	test_config hook.series-1.command "echo 1" --add &&
	test_config hook.series-2.event "test-hook" &&
	test_config hook.series-2.command "echo 2" --add &&

	mkdir -p .git/hooks &&
	write_script .git/hooks/test-hook <<-EOF &&
	echo 3
	EOF

	cat >expected <<-\EOF &&
	1
	2
	3
	EOF

	git hook run --allow-unknown-hook-name -j1 test-hook 2>actual &&
	test_cmp expected actual
'

test_expect_success 'git hook run -j2 runs hooks in parallel' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_when_finished "rm -rf .git/hooks" &&

	mkdir -p .git/hooks &&
	write_sentinel_hook .git/hooks/test-hook &&

	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	test_config hook.hook-2.parallel true &&

	git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&
	echo parallel >expect &&
	test_cmp expect hook.order
'

test_expect_success 'git hook run -j2 overrides parallel=false' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	# hook-1 intentionally has no parallel=true
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	# hook-2 also has no parallel=true

	# -j2 overrides parallel=false; hooks run in parallel with a warning.
	git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&
	echo parallel >expect &&
	test_cmp expect hook.order
'

test_expect_success 'git hook run -j2 warns for hooks not marked parallel=true' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "true" &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command "true" &&
	# neither hook has parallel=true

	git hook run --allow-unknown-hook-name -j2 test-hook >out 2>err &&
	grep "hook .hook-1. is not marked as parallel=true" err &&
	grep "hook .hook-2. is not marked as parallel=true" err
'

test_expect_success 'hook.jobs=1 config runs hooks in series' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&

	# Use two configured hooks so the execution order is deterministic:
	# hook-1 (sentinel) is listed before hook-2 (detector), so hook-1
	# always runs first even in serial mode.
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&

	test_config hook.jobs 1 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo serial >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.jobs=2 config runs hooks in parallel' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_when_finished "rm -rf .git/hooks" &&

	mkdir -p .git/hooks &&
	write_sentinel_hook .git/hooks/test-hook &&

	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	test_config hook.hook-2.parallel true &&

	test_config hook.jobs 2 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo parallel >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.<name>.parallel=true enables parallel execution' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-1.parallel true &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	test_config hook.hook-2.parallel true &&

	test_config hook.jobs 2 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo parallel >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.<name>.parallel=false (default) forces serial execution' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&

	test_config hook.jobs 2 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo serial >expect &&
	test_cmp expect hook.order
'

test_expect_success 'one non-parallel hook forces the whole event to run serially' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-1.parallel true &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	# hook-2 has no parallel=true: should force serial for all

	test_config hook.jobs 2 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo serial >expect &&
	test_cmp expect hook.order
'

test_expect_success 'client hooks: pre-push parallel execution merges stdout to stderr' '
	test_when_finished "rm -rf remote-par stdout.actual stderr.actual" &&
	git init --bare remote-par &&
	git remote add origin-par remote-par &&
	test_commit par-commit &&
	mkdir -p .git/hooks &&
	setup_hooks pre-push &&
	test_config hook.jobs 2 &&
	git push origin-par HEAD:main >stdout.actual 2>stderr.actual &&
	check_stdout_merged_to_stderr pre-push
'

test_expect_success 'client hooks: pre-push runs in parallel when hook.jobs > 1' '
	test_when_finished "rm -rf repo-parallel remote-parallel" &&
	git init --bare remote-parallel &&
	git init repo-parallel &&
	git -C repo-parallel remote add origin ../remote-parallel &&
	test_commit -C repo-parallel A &&

	write_sentinel_hook repo-parallel/.git/hooks/pre-push &&
	git -C repo-parallel config hook.hook-2.event pre-push &&
	git -C repo-parallel config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	git -C repo-parallel config hook.hook-2.parallel true &&

	git -C repo-parallel config hook.jobs 2 &&

	git -C repo-parallel push origin HEAD >out 2>err &&
	echo parallel >expect &&
	test_cmp expect repo-parallel/hook.order
'

test_expect_success 'hook.jobs=2 is ignored for force-serial hooks (pre-commit)' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event pre-commit &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-1.parallel true &&
	test_config hook.hook-2.event pre-commit &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	test_config hook.hook-2.parallel true &&
	test_config hook.jobs 2 &&
	git commit --allow-empty -m "test: verify force-serial on pre-commit" &&
	echo serial >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.<event>.jobs overrides hook.jobs for that event' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-1.parallel true &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	test_config hook.hook-2.parallel true &&

	# Global hook.jobs=1 (serial), but per-event override allows parallel.
	test_config hook.jobs 1 &&
	test_config hook.test-hook.jobs 2 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo parallel >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.<event>.jobs=1 forces serial even when hook.jobs>1' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	test_config hook.hook-1.parallel true &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	test_config hook.hook-2.parallel true &&

	# Global hook.jobs=4 allows parallel, but per-event override forces serial.
	test_config hook.jobs 4 &&
	test_config hook.test-hook.jobs 1 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo serial >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.<event>.jobs still requires hook.<name>.parallel=true' '
	test_when_finished "rm -f sentinel.started sentinel.done hook.order" &&
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command \
	    "touch sentinel.started; sleep 2; touch sentinel.done" &&
	# hook-1 intentionally has no parallel=true
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command \
	    "$(sentinel_detector sentinel hook.order)" &&
	# hook-2 also has no parallel=true

	# Per-event jobs=2 but no hook has parallel=true: must still run serially.
	test_config hook.test-hook.jobs 2 &&

	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	echo serial >expect &&
	test_cmp expect hook.order
'

test_expect_success 'hook.<friendly-name>.jobs warns when name has .command' '
	test_config hook.my-hook.command "true" &&
	test_config hook.my-hook.jobs 2 &&
	git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&
	test_grep "hook.my-hook.jobs.*friendly-name" err
'

test_expect_success 'hook.<friendly-name>.jobs warns when name has .event' '
	test_config hook.my-hook.event test-hook &&
	test_config hook.my-hook.command "true" &&
	test_config hook.my-hook.jobs 2 &&
	git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&
	test_grep "hook.my-hook.jobs.*friendly-name" err
'

test_expect_success 'hook.<friendly-name>.jobs warns when name has .parallel' '
	test_config hook.my-hook.event test-hook &&
	test_config hook.my-hook.command "true" &&
	test_config hook.my-hook.parallel true &&
	test_config hook.my-hook.jobs 2 &&
	git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&
	test_grep "hook.my-hook.jobs.*friendly-name" err
'

test_expect_success 'hook.<event>.jobs does not warn for a real event name' '
	test_config hook.test-hook.jobs 2 &&
	git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&
	test_grep ! "friendly-name" err
'

test_expect_success 'hook.jobs=-1 resolves to online_cpus()' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "true" &&
	test_config hook.hook-1.parallel true &&

	test_config hook.jobs -1 &&

	cpus=$(test-tool online-cpus) &&
	GIT_TRACE2_EVENT="$(pwd)/trace.txt" \
		git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt
'

test_expect_success 'hook.<event>.jobs=-1 resolves to online_cpus()' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "true" &&
	test_config hook.hook-1.parallel true &&

	test_config hook.test-hook.jobs -1 &&

	cpus=$(test-tool online-cpus) &&
	GIT_TRACE2_EVENT="$(pwd)/trace.txt" \
		git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt
'

test_expect_success 'git hook run -j-1 resolves to online_cpus()' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "true" &&
	test_config hook.hook-1.parallel true &&

	cpus=$(test-tool online-cpus) &&
	GIT_TRACE2_EVENT="$(pwd)/trace.txt" \
		git hook run --allow-unknown-hook-name -j-1 test-hook >out 2>err &&
	grep "\"region_enter\".*\"hook\".*\"test-hook\".*\"max:$cpus\"" trace.txt
'

test_expect_success 'hook.jobs rejects values less than -1' '
	test_config hook.jobs -2 &&
	git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&
	test_grep "hook.jobs must be a positive integer or -1" err
'

test_expect_success 'hook.<event>.jobs rejects values less than -1' '
	test_config hook.test-hook.jobs -5 &&
	git hook run --allow-unknown-hook-name --ignore-missing test-hook >out 2>err &&
	test_grep "hook.test-hook.jobs must be a positive integer or -1" err
'

test_expect_success 'hook.<event>.enabled=false skips all hooks for event' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo ran" &&
	test_config hook.test-hook.enabled false &&
	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	test_must_be_empty out
'

test_expect_success 'hook.<event>.enabled=true does not suppress hooks' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo ran" &&
	test_config hook.test-hook.enabled true &&
	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	test_grep "ran" err
'

test_expect_success 'hook.<event>.enabled=false does not affect other events' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo ran" &&
	test_config hook.other-event.enabled false &&
	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	test_grep "ran" err
'

test_expect_success 'hook.<friendly-name>.enabled=false still disables that hook' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo hook-1" &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command "echo hook-2" &&
	test_config hook.hook-1.enabled false &&
	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	test_grep ! "hook-1" err &&
	test_grep "hook-2" err
'

test_expect_success 'git hook list shows event-disabled hooks as event-disabled' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo ran" &&
	test_config hook.hook-2.event test-hook &&
	test_config hook.hook-2.command "echo ran" &&
	test_config hook.test-hook.enabled false &&
	git hook list --allow-unknown-hook-name test-hook >actual &&
	test_grep "^event-disabled	hook-1$" actual &&
	test_grep "^event-disabled	hook-2$" actual
'

test_expect_success 'git hook list shows scope with event-disabled' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo ran" &&
	test_config hook.test-hook.enabled false &&
	git hook list --allow-unknown-hook-name --show-scope test-hook >actual &&
	test_grep "^local	event-disabled	hook-1$" actual
'

test_expect_success 'git hook list still shows hooks when event is disabled' '
	test_config hook.hook-1.event test-hook &&
	test_config hook.hook-1.command "echo ran" &&
	test_config hook.test-hook.enabled false &&
	git hook list --allow-unknown-hook-name test-hook >actual &&
	test_grep "event-disabled" actual
'

test_expect_success 'friendly-name matching known event name is rejected' '
	test_config hook.pre-commit.event pre-commit &&
	test_config hook.pre-commit.command "echo oops" &&
	test_must_fail git hook run pre-commit 2>err &&
	test_grep "collides with a known event name" err
'

test_expect_success 'friendly-name matching known event name is rejected even for different event' '
	test_config hook.pre-commit.event post-commit &&
	test_config hook.pre-commit.command "echo oops" &&
	test_must_fail git hook run post-commit 2>err &&
	test_grep "collides with a known event name" err
'

test_expect_success 'friendly-name matching unknown event warns' '
	test_config hook.test-hook.event test-hook &&
	test_config hook.test-hook.command "echo ran" &&
	git hook run --allow-unknown-hook-name test-hook >out 2>err &&
	test_grep "same as its event" err
'

test_expect_success 'hooks in parallel that do not read input' '
	# Add this to our $PATH to avoid having to write the whole trash
	# directory into our config options, which would require quoting.
	mkdir bin &&
	PATH=$PWD/bin:$PATH &&

	write_script bin/hook-fast <<-\EOF &&
	# This hook does not read its input, so the parent process
	# may see SIGPIPE if it is not ignored. It should happen
	# relatively quickly.
	exit 0
	EOF

	write_script bin/hook-slow <<-\EOF &&
	# This hook is slow, so we expect it to still be running
	# when the other hook has exited (and the parent has a pipe error
	# writing to it).
	#
	# So we want to be slow enough that we expect this to happen, but not
	# so slow that the test takes forever. 1 second is probably enough
	# in practice (and if it is occasionally not on a loaded system, we
	# will err on the side of having the test pass).
	sleep 1
	exit 0
	EOF

	git init --bare parallel.git &&
	git -C parallel.git config hook.fast.command "hook-fast" &&
	git -C parallel.git config hook.fast.event pre-receive &&
	git -C parallel.git config hook.fast.parallel true &&
	git -C parallel.git config hook.slow.command "hook-slow" &&
	git -C parallel.git config hook.slow.event pre-receive &&
	git -C parallel.git config hook.slow.parallel true &&
	git -C parallel.git config hook.jobs 2 &&

	git push ./parallel.git "+refs/heads/*:refs/heads/*"
'

test_done
