summary refs log tree commit diff
path: root/t
diff options
context:
space:
mode:
authorJunio C Hamano <gitster@pobox.com>2022-03-30 18:01:11 -0700
committerJunio C Hamano <gitster@pobox.com>2022-03-30 18:01:11 -0700
commita5bf611cc4f14f062cd74965deafe617048bff05 (patch)
tree47dd4d166bf8dc324a619d591ad03b918a93211a /t
parent2ea7e40c53512c410cfc769f6817d31c3bec9941 (diff)
parentf6db603c7a051d9d0d1ce135673ce77a5b8b7a4c (diff)
Merge branch 'ab/hook-tests-updates'
Update tests around the use of hook scripts.

* ab/hook-tests-updates:
  http tests: use "test_hook" for "smart" and "dumb" http tests
  proc-receive hook tests: use "test_hook" instead of "write_script"
  tests: extend "test_hook" for "rm" and "chmod -x", convert "$HOOK"
  tests: use "test_hook" for misc "mkdir -p" and "chmod" cases
  tests: change "mkdir -p && write_script" to use "test_hook"
  tests: change "cat && chmod +x" to use "test_hook"
  gc + p4 tests: use "test_hook", remove sub-shells
  fetch+push tests: use "test_hook" and "test_when_finished" pattern
  bugreport tests: tighten up "git bugreport -s hooks" test
  tests: assume the hooks are disabled by default
  http tests: don't rely on "hook/post-update.sample"
  hook tests: turn exit code assertions into a loop
  test-lib-functions: add and use a "test_hook" wrapper
Diffstat (limited to 't')
-rwxr-xr-xt/t0029-core-unsetenvvars.sh3
-rwxr-xr-xt/t0091-bugreport.sh26
-rwxr-xr-xt/t1350-config-hooks-path.sh4
-rwxr-xr-xt/t1416-ref-transaction-hooks.sh27
-rwxr-xr-xt/t1800-hook.sh45
-rwxr-xr-xt/t2400-worktree-add.sh5
-rwxr-xr-xt/t3404-rebase-interactive.sh10
-rwxr-xr-xt/t3412-rebase-root.sh18
-rwxr-xr-xt/t3413-rebase-hook.sh18
-rwxr-xr-xt/t3430-rebase-merges.sh6
-rwxr-xr-xt/t4150-am.sh24
-rwxr-xr-xt/t5401-update-hooks.sh64
-rwxr-xr-xt/t5402-post-merge-hook.sh16
-rwxr-xr-xt/t5403-post-checkout-hook.sh3
-rwxr-xr-xt/t5406-remote-rejects.sh2
-rwxr-xr-xt/t5407-post-rewrite-hook.sh14
-rwxr-xr-xt/t5409-colorize-remote-messages.sh2
-rwxr-xr-xt/t5411-proc-receive-hook.sh4
-rw-r--r--t/t5411/once-0010-report-status-v1.sh2
-rw-r--r--t/t5411/test-0002-pre-receive-declined.sh4
-rw-r--r--t/t5411/test-0003-pre-receive-declined--porcelain.sh2
-rw-r--r--t/t5411/test-0013-bad-protocol.sh20
-rw-r--r--t/t5411/test-0014-bad-protocol--porcelain.sh18
-rw-r--r--t/t5411/test-0020-report-ng.sh4
-rw-r--r--t/t5411/test-0021-report-ng--porcelain.sh4
-rw-r--r--t/t5411/test-0022-report-unexpect-ref.sh2
-rw-r--r--t/t5411/test-0023-report-unexpect-ref--porcelain.sh2
-rw-r--r--t/t5411/test-0024-report-unknown-ref.sh2
-rw-r--r--t/t5411/test-0025-report-unknown-ref--porcelain.sh2
-rw-r--r--t/t5411/test-0026-push-options.sh6
-rw-r--r--t/t5411/test-0027-push-options--porcelain.sh6
-rw-r--r--t/t5411/test-0030-report-ok.sh2
-rw-r--r--t/t5411/test-0031-report-ok--porcelain.sh2
-rw-r--r--t/t5411/test-0032-report-with-options.sh14
-rw-r--r--t/t5411/test-0033-report-with-options--porcelain.sh14
-rw-r--r--t/t5411/test-0034-report-ft.sh2
-rw-r--r--t/t5411/test-0035-report-ft--porcelain.sh2
-rw-r--r--t/t5411/test-0036-report-multi-rewrite-for-one-ref.sh6
-rw-r--r--t/t5411/test-0037-report-multi-rewrite-for-one-ref--porcelain.sh6
-rw-r--r--t/t5411/test-0038-report-mixed-refs.sh2
-rw-r--r--t/t5411/test-0039-report-mixed-refs--porcelain.sh2
-rw-r--r--t/t5411/test-0040-process-all-refs.sh2
-rw-r--r--t/t5411/test-0041-process-all-refs--porcelain.sh2
-rw-r--r--t/t5411/test-0050-proc-receive-refs-with-modifiers.sh4
-rwxr-xr-xt/t5503-tagfollow.sh4
-rwxr-xr-xt/t5510-fetch.sh8
-rwxr-xr-xt/t5516-fetch-push.sh192
-rwxr-xr-xt/t5521-pull-options.sh4
-rwxr-xr-xt/t5534-push-signed.sh26
-rwxr-xr-xt/t5540-http-push-webdav.sh4
-rwxr-xr-xt/t5541-http-push-smart.sh32
-rwxr-xr-xt/t5543-atomic-push.sh14
-rwxr-xr-xt/t5547-push-quarantine.sh4
-rwxr-xr-xt/t5548-push-porcelain.sh2
-rwxr-xr-xt/t5550-http-fetch-dumb.sh25
-rwxr-xr-xt/t5571-pre-push-hook.sh38
-rwxr-xr-xt/t5601-clone.sh4
-rwxr-xr-xt/t6500-gc.sh22
-rwxr-xr-xt/t7113-post-index-change-hook.sh7
-rwxr-xr-xt/t7503-pre-commit-and-pre-merge-commit-hooks.sh150
-rwxr-xr-xt/t7504-commit-msg-hook.sh43
-rwxr-xr-xt/t7505-prepare-commit-msg-hook.sh43
-rwxr-xr-xt/t7519-status-fsmonitor.sh20
-rwxr-xr-xt/t7520-ignored-hook-warning.sh11
-rwxr-xr-xt/t9001-send-email.sh4
-rwxr-xr-xt/t9800-git-p4-basic.sh23
-rw-r--r--t/test-lib-functions.sh76
67 files changed, 575 insertions, 606 deletions
diff --git a/t/t0029-core-unsetenvvars.sh b/t/t0029-core-unsetenvvars.sh
index b138e1d9cb..4e8e90dd98 100755
--- a/t/t0029-core-unsetenvvars.sh
+++ b/t/t0029-core-unsetenvvars.sh
@@ -12,8 +12,7 @@ then
 fi
 
 test_expect_success 'setup' '
-	mkdir -p "$TRASH_DIRECTORY/.git/hooks" &&
-	write_script "$TRASH_DIRECTORY/.git/hooks/pre-commit" <<-\EOF
+	test_hook --setup pre-commit <<-\EOF
 	echo $HOBBES >&2
 	EOF
 '
diff --git a/t/t0091-bugreport.sh b/t/t0091-bugreport.sh
index eeedbfa919..08f5fe9cae 100755
--- a/t/t0091-bugreport.sh
+++ b/t/t0091-bugreport.sh
@@ -60,18 +60,22 @@ test_expect_success 'can create leading directories outside of a git dir' '
 
 test_expect_success 'indicates populated hooks' '
 	test_when_finished rm git-bugreport-hooks.txt &&
-	test_when_finished rm -fr .git/hooks &&
-	rm -fr .git/hooks &&
-	mkdir .git/hooks &&
-	for hook in applypatch-msg prepare-commit-msg.sample
-	do
-		write_script ".git/hooks/$hook" <<-EOF || return 1
-		echo "hook $hook exists"
-		EOF
-	done &&
+
+	test_hook applypatch-msg <<-\EOF &&
+	true
+	EOF
+	test_hook unknown-hook <<-\EOF &&
+	true
+	EOF
 	git bugreport -s hooks &&
-	grep applypatch-msg git-bugreport-hooks.txt &&
-	! grep prepare-commit-msg git-bugreport-hooks.txt
+
+	sort >expect <<-\EOF &&
+	[Enabled Hooks]
+	applypatch-msg
+	EOF
+
+	sed -ne "/^\[Enabled Hooks\]$/,/^$/p" <git-bugreport-hooks.txt >actual &&
+	test_cmp expect actual
 '
 
 test_done
diff --git a/t/t1350-config-hooks-path.sh b/t/t1350-config-hooks-path.sh
index fa9647a7c0..f6dc83e2aa 100755
--- a/t/t1350-config-hooks-path.sh
+++ b/t/t1350-config-hooks-path.sh
@@ -6,11 +6,11 @@ test_description='Test the core.hooksPath configuration variable'
 
 test_expect_success 'set up a pre-commit hook in core.hooksPath' '
 	>actual &&
-	mkdir -p .git/custom-hooks .git/hooks &&
+	mkdir -p .git/custom-hooks &&
 	write_script .git/custom-hooks/pre-commit <<-\EOF &&
 	echo CUSTOM >>actual
 	EOF
-	write_script .git/hooks/pre-commit <<-\EOF
+	test_hook --setup pre-commit <<-\EOF
 	echo NORMAL >>actual
 	EOF
 '
diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh
index 4e1e84a91f..085a7a46f2 100755
--- a/t/t1416-ref-transaction-hooks.sh
+++ b/t/t1416-ref-transaction-hooks.sh
@@ -8,7 +8,6 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
 . ./test-lib.sh
 
 test_expect_success setup '
-	mkdir -p .git/hooks &&
 	test_commit PRE &&
 	PRE_OID=$(git rev-parse PRE) &&
 	test_commit POST &&
@@ -16,9 +15,8 @@ test_expect_success setup '
 '
 
 test_expect_success 'hook allows updating ref if successful' '
-	test_when_finished "rm .git/hooks/reference-transaction" &&
 	git reset --hard PRE &&
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		echo "$*" >>actual
 	EOF
 	cat >expect <<-EOF &&
@@ -30,9 +28,8 @@ test_expect_success 'hook allows updating ref if successful' '
 '
 
 test_expect_success 'hook aborts updating ref in prepared state' '
-	test_when_finished "rm .git/hooks/reference-transaction" &&
 	git reset --hard PRE &&
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		if test "$1" = prepared
 		then
 			exit 1
@@ -43,9 +40,9 @@ test_expect_success 'hook aborts updating ref in prepared state' '
 '
 
 test_expect_success 'hook gets all queued updates in prepared state' '
-	test_when_finished "rm .git/hooks/reference-transaction actual" &&
+	test_when_finished "rm actual" &&
 	git reset --hard PRE &&
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		if test "$1" = prepared
 		then
 			while read -r line
@@ -66,9 +63,9 @@ test_expect_success 'hook gets all queued updates in prepared state' '
 '
 
 test_expect_success 'hook gets all queued updates in committed state' '
-	test_when_finished "rm .git/hooks/reference-transaction actual" &&
+	test_when_finished "rm actual" &&
 	git reset --hard PRE &&
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		if test "$1" = committed
 		then
 			while read -r line
@@ -86,9 +83,9 @@ test_expect_success 'hook gets all queued updates in committed state' '
 '
 
 test_expect_success 'hook gets all queued updates in aborted state' '
-	test_when_finished "rm .git/hooks/reference-transaction actual" &&
+	test_when_finished "rm actual" &&
 	git reset --hard PRE &&
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		if test "$1" = aborted
 		then
 			while read -r line
@@ -115,11 +112,11 @@ test_expect_success 'interleaving hook calls succeed' '
 
 	git init --bare target-repo.git &&
 
-	write_script target-repo.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C target-repo.git reference-transaction <<-\EOF &&
 		echo $0 "$@" >>actual
 	EOF
 
-	write_script target-repo.git/hooks/update <<-\EOF &&
+	test_hook -C target-repo.git update <<-\EOF &&
 		echo $0 "$@" >>actual
 	EOF
 
@@ -140,7 +137,7 @@ test_expect_success 'hook does not get called on packing refs' '
 	# Pack references first such that we are in a known state.
 	git pack-refs --all &&
 
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		echo "$@" >>actual
 		cat >>actual
 	EOF
@@ -166,7 +163,7 @@ test_expect_success 'deleting packed ref calls hook once' '
 	git update-ref refs/heads/to-be-deleted $POST_OID &&
 	git pack-refs --all &&
 
-	write_script .git/hooks/reference-transaction <<-\EOF &&
+	test_hook reference-transaction <<-\EOF &&
 		echo "$@" >>actual
 		cat >>actual
 	EOF
diff --git a/t/t1800-hook.sh b/t/t1800-hook.sh
index 29718aa991..26ed5e11bc 100755
--- a/t/t1800-hook.sh
+++ b/t/t1800-hook.sh
@@ -27,7 +27,7 @@ test_expect_success 'git hook run: nonexistent hook with --ignore-missing' '
 '
 
 test_expect_success 'git hook run: basic' '
-	write_script .git/hooks/test-hook <<-EOF &&
+	test_hook test-hook <<-EOF &&
 	echo Test hook
 	EOF
 
@@ -39,7 +39,7 @@ test_expect_success 'git hook run: basic' '
 '
 
 test_expect_success 'git hook run: stdout and stderr both write to our stderr' '
-	write_script .git/hooks/test-hook <<-EOF &&
+	test_hook test-hook <<-EOF &&
 	echo >&1 Will end up on stderr
 	echo >&2 Will end up on stderr
 	EOF
@@ -53,38 +53,23 @@ test_expect_success 'git hook run: stdout and stderr both write to our stderr' '
 	test_must_be_empty stdout.actual
 '
 
-test_expect_success 'git hook run: exit codes are passed along' '
-	write_script .git/hooks/test-hook <<-EOF &&
-	exit 1
-	EOF
-
-	test_expect_code 1 git hook run test-hook &&
-
-	write_script .git/hooks/test-hook <<-EOF &&
-	exit 2
-	EOF
-
-	test_expect_code 2 git hook run test-hook &&
-
-	write_script .git/hooks/test-hook <<-EOF &&
-	exit 128
-	EOF
+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 128 git hook run test-hook &&
-
-	write_script .git/hooks/test-hook <<-EOF &&
-	exit 129
-	EOF
-
-	test_expect_code 129 git hook run test-hook
-'
+		test_expect_code $code git hook run test-hook
+	'
+done
 
 test_expect_success 'git hook run arg u ments without -- is not allowed' '
 	test_expect_code 129 git hook run test-hook arg u ments
 '
 
 test_expect_success 'git hook run -- pass arguments' '
-	write_script .git/hooks/test-hook <<-\EOF &&
+	test_hook test-hook <<-\EOF &&
 	echo $1
 	echo $2
 	EOF
@@ -99,7 +84,7 @@ test_expect_success 'git hook run -- pass arguments' '
 '
 
 test_expect_success 'git hook run -- out-of-repo runs excluded' '
-	write_script .git/hooks/test-hook <<-EOF &&
+	test_hook test-hook <<-EOF &&
 	echo Test hook
 	EOF
 
@@ -120,6 +105,10 @@ test_expect_success 'git -c core.hooksPath=<PATH> hook run' '
 	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 &&
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index 43139af08f..2f564d533d 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -559,10 +559,7 @@ test_expect_success 'git worktree --no-guess-remote option overrides config' '
 '
 
 post_checkout_hook () {
-	gitdir=${1:-.git}
-	test_when_finished "rm -f $gitdir/hooks/post-checkout" &&
-	mkdir -p $gitdir/hooks &&
-	write_script $gitdir/hooks/post-checkout <<-\EOF
+	test_hook -C "$1" post-checkout <<-\EOF
 	{
 		echo $*
 		git rev-parse --git-dir --show-toplevel
diff --git a/t/t3404-rebase-interactive.sh b/t/t3404-rebase-interactive.sh
index a38f2da769..f31afd4a54 100755
--- a/t/t3404-rebase-interactive.sh
+++ b/t/t3404-rebase-interactive.sh
@@ -619,9 +619,7 @@ test_expect_success 'rebase a detached HEAD' '
 '
 
 test_expect_success 'rebase a commit violating pre-commit' '
-
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/pre-commit <<-\EOF &&
+	test_hook pre-commit <<-\EOF &&
 	test -z "$(git diff --cached --check)"
 	EOF
 	echo "monde! " >> file1 &&
@@ -636,8 +634,6 @@ test_expect_success 'rebase a commit violating pre-commit' '
 '
 
 test_expect_success 'rebase with a file named HEAD in worktree' '
-
-	rm -fr .git/hooks &&
 	git reset --hard &&
 	git checkout -b branch3 A &&
 
@@ -1688,10 +1684,8 @@ test_expect_success 'valid author header when author contains single quote' '
 '
 
 test_expect_success 'post-commit hook is called' '
-	test_when_finished "rm -f .git/hooks/post-commit" &&
 	>actual &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/post-commit <<-\EOS &&
+	test_hook post-commit <<-\EOS &&
 	git rev-parse HEAD >>actual
 	EOS
 	(
diff --git a/t/t3412-rebase-root.sh b/t/t3412-rebase-root.sh
index 1e9f7833dd..58371d8a54 100755
--- a/t/t3412-rebase-root.sh
+++ b/t/t3412-rebase-root.sh
@@ -31,12 +31,9 @@ test_expect_success 'rebase --root fails with too many args' '
 '
 
 test_expect_success 'setup pre-rebase hook' '
-	mkdir -p .git/hooks &&
-	cat >.git/hooks/pre-rebase <<EOF &&
-#!$SHELL_PATH
-echo "\$1,\$2" >.git/PRE-REBASE-INPUT
-EOF
-	chmod +x .git/hooks/pre-rebase
+	test_hook --setup pre-rebase <<-\EOF
+	echo "$1,$2" >.git/PRE-REBASE-INPUT
+	EOF
 '
 cat > expect <<EOF
 4
@@ -141,12 +138,9 @@ commit work7~5
 EOF
 
 test_expect_success 'setup pre-rebase hook that fails' '
-	mkdir -p .git/hooks &&
-	cat >.git/hooks/pre-rebase <<EOF &&
-#!$SHELL_PATH
-false
-EOF
-	chmod +x .git/hooks/pre-rebase
+	test_hook --setup --clobber pre-rebase <<-\EOF
+	false
+	EOF
 '
 
 test_expect_success 'pre-rebase hook stops rebase' '
diff --git a/t/t3413-rebase-hook.sh b/t/t3413-rebase-hook.sh
index b4acb3be5c..9fab0d779b 100755
--- a/t/t3413-rebase-hook.sh
+++ b/t/t3413-rebase-hook.sh
@@ -41,12 +41,9 @@ test_expect_success 'rebase -i' '
 '
 
 test_expect_success 'setup pre-rebase hook' '
-	mkdir -p .git/hooks &&
-	cat >.git/hooks/pre-rebase <<EOF &&
-#!$SHELL_PATH
-echo "\$1,\$2" >.git/PRE-REBASE-INPUT
-EOF
-	chmod +x .git/hooks/pre-rebase
+	test_hook --setup pre-rebase <<-\EOF
+	echo "$1,$2" >.git/PRE-REBASE-INPUT
+	EOF
 '
 
 test_expect_success 'pre-rebase hook gets correct input (1)' '
@@ -102,12 +99,9 @@ test_expect_success 'pre-rebase hook gets correct input (6)' '
 '
 
 test_expect_success 'setup pre-rebase hook that fails' '
-	mkdir -p .git/hooks &&
-	cat >.git/hooks/pre-rebase <<EOF &&
-#!$SHELL_PATH
-false
-EOF
-	chmod +x .git/hooks/pre-rebase
+	test_hook --setup --clobber pre-rebase <<-\EOF
+	false
+	EOF
 '
 
 test_expect_success 'pre-rebase hook stops rebase (1)' '
diff --git a/t/t3430-rebase-merges.sh b/t/t3430-rebase-merges.sh
index 43c82d9a33..f351701fec 100755
--- a/t/t3430-rebase-merges.sh
+++ b/t/t3430-rebase-merges.sh
@@ -292,9 +292,9 @@ test_expect_success 'post-rewrite hook and fixups work for merges' '
 	git commit --fixup HEAD same2.t &&
 	fixup="$(git rev-parse HEAD)" &&
 
-	mkdir -p .git/hooks &&
-	test_when_finished "rm .git/hooks/post-rewrite" &&
-	echo "cat >actual" | write_script .git/hooks/post-rewrite &&
+	test_hook post-rewrite <<-\EOF &&
+	cat >actual
+	EOF
 
 	test_tick &&
 	git rebase -i --autosquash -r HEAD^^^ &&
diff --git a/t/t4150-am.sh b/t/t4150-am.sh
index 159fae8d01..cdad4b6880 100755
--- a/t/t4150-am.sh
+++ b/t/t4150-am.sh
@@ -315,12 +315,10 @@ test_expect_success 'am --patch-format=hg applies hg patch' '
 '
 
 test_expect_success 'am with applypatch-msg hook' '
-	test_when_finished "rm -f .git/hooks/applypatch-msg" &&
 	rm -fr .git/rebase-apply &&
 	git reset --hard &&
 	git checkout first &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/applypatch-msg <<-\EOF &&
+	test_hook applypatch-msg <<-\EOF &&
 	cat "$1" >actual-msg &&
 	echo hook-message >"$1"
 	EOF
@@ -335,12 +333,10 @@ test_expect_success 'am with applypatch-msg hook' '
 '
 
 test_expect_success 'am with failing applypatch-msg hook' '
-	test_when_finished "rm -f .git/hooks/applypatch-msg" &&
 	rm -fr .git/rebase-apply &&
 	git reset --hard &&
 	git checkout first &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/applypatch-msg <<-\EOF &&
+	test_hook applypatch-msg <<-\EOF &&
 	exit 1
 	EOF
 	test_must_fail git am patch1 &&
@@ -350,12 +346,10 @@ test_expect_success 'am with failing applypatch-msg hook' '
 '
 
 test_expect_success 'am with pre-applypatch hook' '
-	test_when_finished "rm -f .git/hooks/pre-applypatch" &&
 	rm -fr .git/rebase-apply &&
 	git reset --hard &&
 	git checkout first &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/pre-applypatch <<-\EOF &&
+	test_hook pre-applypatch <<-\EOF &&
 	git diff first >diff.actual
 	exit 0
 	EOF
@@ -368,12 +362,10 @@ test_expect_success 'am with pre-applypatch hook' '
 '
 
 test_expect_success 'am with failing pre-applypatch hook' '
-	test_when_finished "rm -f .git/hooks/pre-applypatch" &&
 	rm -fr .git/rebase-apply &&
 	git reset --hard &&
 	git checkout first &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/pre-applypatch <<-\EOF &&
+	test_hook pre-applypatch <<-\EOF &&
 	exit 1
 	EOF
 	test_must_fail git am patch1 &&
@@ -383,12 +375,10 @@ test_expect_success 'am with failing pre-applypatch hook' '
 '
 
 test_expect_success 'am with post-applypatch hook' '
-	test_when_finished "rm -f .git/hooks/post-applypatch" &&
 	rm -fr .git/rebase-apply &&
 	git reset --hard &&
 	git checkout first &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/post-applypatch <<-\EOF &&
+	test_hook post-applypatch <<-\EOF &&
 	git rev-parse HEAD >head.actual
 	git diff second >diff.actual
 	exit 0
@@ -403,12 +393,10 @@ test_expect_success 'am with post-applypatch hook' '
 '
 
 test_expect_success 'am with failing post-applypatch hook' '
-	test_when_finished "rm -f .git/hooks/post-applypatch" &&
 	rm -fr .git/rebase-apply &&
 	git reset --hard &&
 	git checkout first &&
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/post-applypatch <<-\EOF &&
+	test_hook post-applypatch <<-\EOF &&
 	git rev-parse HEAD >head.actual
 	exit 1
 	EOF
diff --git a/t/t5401-update-hooks.sh b/t/t5401-update-hooks.sh
index 6012cc8172..001b7a17ad 100755
--- a/t/t5401-update-hooks.sh
+++ b/t/t5401-update-hooks.sh
@@ -20,45 +20,37 @@ test_expect_success setup '
 	git clone --bare ./. victim.git &&
 	GIT_DIR=victim.git git update-ref refs/heads/tofail $commit1 &&
 	git update-ref refs/heads/main $commit1 &&
-	git update-ref refs/heads/tofail $commit0
-'
+	git update-ref refs/heads/tofail $commit0 &&
 
-cat >victim.git/hooks/pre-receive <<'EOF'
-#!/bin/sh
-printf %s "$@" >>$GIT_DIR/pre-receive.args
-cat - >$GIT_DIR/pre-receive.stdin
-echo STDOUT pre-receive
-echo STDERR pre-receive >&2
-EOF
-chmod u+x victim.git/hooks/pre-receive
+	test_hook --setup -C victim.git pre-receive <<-\EOF &&
+	printf %s "$@" >>$GIT_DIR/pre-receive.args
+	cat - >$GIT_DIR/pre-receive.stdin
+	echo STDOUT pre-receive
+	echo STDERR pre-receive >&2
+	EOF
 
-cat >victim.git/hooks/update <<'EOF'
-#!/bin/sh
-echo "$@" >>$GIT_DIR/update.args
-read x; printf %s "$x" >$GIT_DIR/update.stdin
-echo STDOUT update $1
-echo STDERR update $1 >&2
-test "$1" = refs/heads/main || exit
-EOF
-chmod u+x victim.git/hooks/update
+	test_hook --setup -C victim.git update <<-\EOF &&
+	echo "$@" >>$GIT_DIR/update.args
+	read x; printf %s "$x" >$GIT_DIR/update.stdin
+	echo STDOUT update $1
+	echo STDERR update $1 >&2
+	test "$1" = refs/heads/main || exit
+	EOF
 
-cat >victim.git/hooks/post-receive <<'EOF'
-#!/bin/sh
-printf %s "$@" >>$GIT_DIR/post-receive.args
-cat - >$GIT_DIR/post-receive.stdin
-echo STDOUT post-receive
-echo STDERR post-receive >&2
-EOF
-chmod u+x victim.git/hooks/post-receive
+	test_hook --setup -C victim.git post-receive <<-\EOF &&
+	printf %s "$@" >>$GIT_DIR/post-receive.args
+	cat - >$GIT_DIR/post-receive.stdin
+	echo STDOUT post-receive
+	echo STDERR post-receive >&2
+	EOF
 
-cat >victim.git/hooks/post-update <<'EOF'
-#!/bin/sh
-echo "$@" >>$GIT_DIR/post-update.args
-read x; printf %s "$x" >$GIT_DIR/post-update.stdin
-echo STDOUT post-update
-echo STDERR post-update >&2
-EOF
-chmod u+x victim.git/hooks/post-update
+	test_hook --setup -C victim.git post-update <<-\EOF
+	echo "$@" >>$GIT_DIR/post-update.args
+	read x; printf %s "$x" >$GIT_DIR/post-update.stdin
+	echo STDOUT post-update
+	echo STDERR post-update >&2
+	EOF
+'
 
 test_expect_success push '
 	test_must_fail git send-pack --force ./victim.git \
@@ -136,7 +128,7 @@ test_expect_success 'send-pack stderr contains hook messages' '
 '
 
 test_expect_success 'pre-receive hook that forgets to read its input' '
-	write_script victim.git/hooks/pre-receive <<-\EOF &&
+	test_hook --clobber -C victim.git pre-receive <<-\EOF &&
 	exit 0
 	EOF
 	rm -f victim.git/hooks/update victim.git/hooks/post-update &&
diff --git a/t/t5402-post-merge-hook.sh b/t/t5402-post-merge-hook.sh
index 3e5e19c719..915af2de95 100755
--- a/t/t5402-post-merge-hook.sh
+++ b/t/t5402-post-merge-hook.sh
@@ -25,13 +25,15 @@ test_expect_success setup '
 	GIT_DIR=clone2/.git git update-index --add a
 '
 
-for clone in 1 2; do
-	cat >clone${clone}/.git/hooks/post-merge <<'EOF'
-#!/bin/sh
-echo $@ >> $GIT_DIR/post-merge.args
-EOF
-	chmod u+x clone${clone}/.git/hooks/post-merge
-done
+test_expect_success 'setup clone hooks' '
+	test_when_finished "rm -f hook" &&
+	cat >hook <<-\EOF &&
+	echo $@ >>$GIT_DIR/post-merge.args
+	EOF
+
+	test_hook --setup -C clone1 post-merge <hook &&
+	test_hook --setup -C clone2 post-merge <hook
+'
 
 test_expect_success 'post-merge does not run for up-to-date ' '
 	GIT_DIR=clone1/.git git merge $commit0 &&
diff --git a/t/t5403-post-checkout-hook.sh b/t/t5403-post-checkout-hook.sh
index d118181690..978f240cda 100755
--- a/t/t5403-post-checkout-hook.sh
+++ b/t/t5403-post-checkout-hook.sh
@@ -10,8 +10,7 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
 . ./test-lib.sh
 
 test_expect_success setup '
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/post-checkout <<-\EOF &&
+	test_hook --setup post-checkout <<-\EOF &&
 	echo "$@" >.git/post-checkout.args
 	EOF
 	test_commit one &&
diff --git a/t/t5406-remote-rejects.sh b/t/t5406-remote-rejects.sh
index 5c509db6fc..dcbeb42082 100755
--- a/t/t5406-remote-rejects.sh
+++ b/t/t5406-remote-rejects.sh
@@ -5,7 +5,7 @@ test_description='remote push rejects are reported by client'
 . ./test-lib.sh
 
 test_expect_success 'setup' '
-	write_script .git/hooks/update <<-\EOF &&
+	test_hook update <<-\EOF &&
 	exit 1
 	EOF
 	echo 1 >file &&
diff --git a/t/t5407-post-rewrite-hook.sh b/t/t5407-post-rewrite-hook.sh
index 6da8d760e2..5f3ff051ca 100755
--- a/t/t5407-post-rewrite-hook.sh
+++ b/t/t5407-post-rewrite-hook.sh
@@ -17,15 +17,13 @@ test_expect_success 'setup' '
 	git checkout A^0 &&
 	test_commit E bar E &&
 	test_commit F foo F &&
-	git checkout main
-'
+	git checkout main &&
 
-cat >.git/hooks/post-rewrite <<EOF
-#!/bin/sh
-echo \$@ > "$TRASH_DIRECTORY"/post-rewrite.args
-cat > "$TRASH_DIRECTORY"/post-rewrite.data
-EOF
-chmod u+x .git/hooks/post-rewrite
+	test_hook --setup post-rewrite <<-EOF
+	echo \$@ > "$TRASH_DIRECTORY"/post-rewrite.args
+	cat > "$TRASH_DIRECTORY"/post-rewrite.data
+	EOF
+'
 
 clear_hook_input () {
 	rm -f post-rewrite.args post-rewrite.data
diff --git a/t/t5409-colorize-remote-messages.sh b/t/t5409-colorize-remote-messages.sh
index 9f1a483f42..fa5de4500a 100755
--- a/t/t5409-colorize-remote-messages.sh
+++ b/t/t5409-colorize-remote-messages.sh
@@ -5,7 +5,7 @@ test_description='remote messages are colorized on the client'
 . ./test-lib.sh
 
 test_expect_success 'setup' '
-	write_script .git/hooks/update <<-\EOF &&
+	test_hook --setup update <<-\EOF &&
 	echo error: error
 	echo ERROR: also highlighted
 	echo hint: hint
diff --git a/t/t5411-proc-receive-hook.sh b/t/t5411-proc-receive-hook.sh
index 98b0e81208..92cf52c6d4 100755
--- a/t/t5411-proc-receive-hook.sh
+++ b/t/t5411-proc-receive-hook.sh
@@ -36,7 +36,7 @@ setup_upstream_and_workbench () {
 		TAG=$(git -C workbench rev-parse v123) &&
 
 		# setup pre-receive hook
-		write_script upstream.git/hooks/pre-receive <<-\EOF &&
+		test_hook --setup -C upstream.git pre-receive <<-\EOF &&
 		exec >&2
 		echo "# pre-receive hook"
 		while read old new ref
@@ -46,7 +46,7 @@ setup_upstream_and_workbench () {
 		EOF
 
 		# setup post-receive hook
-		write_script upstream.git/hooks/post-receive <<-\EOF &&
+		test_hook --setup -C upstream.git post-receive <<-\EOF &&
 		exec >&2
 		echo "# post-receive hook"
 		while read old new ref
diff --git a/t/t5411/once-0010-report-status-v1.sh b/t/t5411/once-0010-report-status-v1.sh
index 297b10925d..f9ffb01e50 100644
--- a/t/t5411/once-0010-report-status-v1.sh
+++ b/t/t5411/once-0010-report-status-v1.sh
@@ -3,7 +3,7 @@ test_expect_success "setup receive.procReceiveRefs" '
 '
 
 test_expect_success "setup proc-receive hook" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic1" \
diff --git a/t/t5411/test-0002-pre-receive-declined.sh b/t/t5411/test-0002-pre-receive-declined.sh
index 0c3490c9b1..98a9d13041 100644
--- a/t/t5411/test-0002-pre-receive-declined.sh
+++ b/t/t5411/test-0002-pre-receive-declined.sh
@@ -1,6 +1,6 @@
 test_expect_success "setup pre-receive hook ($PROTOCOL)" '
 	mv "$upstream/hooks/pre-receive" "$upstream/hooks/pre-receive.ok" &&
-	write_script "$upstream/hooks/pre-receive" <<-EOF
+	test_hook -C "$upstream" --clobber pre-receive <<-\EOF
 	exit 1
 	EOF
 '
@@ -21,7 +21,7 @@ test_expect_success "git-push is declined ($PROTOCOL)" '
 	EOF
 	test_cmp expect actual &&
 
-	test_cmp_refs -C "$upstream" <<-EOF
+	test_cmp_refs -C "$upstream" <<-\EOF
 	<COMMIT-A> refs/heads/main
 	EOF
 '
diff --git a/t/t5411/test-0003-pre-receive-declined--porcelain.sh b/t/t5411/test-0003-pre-receive-declined--porcelain.sh
index 2393b04ad9..67ca6dc4f8 100644
--- a/t/t5411/test-0003-pre-receive-declined--porcelain.sh
+++ b/t/t5411/test-0003-pre-receive-declined--porcelain.sh
@@ -1,6 +1,6 @@
 test_expect_success "setup pre-receive hook ($PROTOCOL/porcelain)" '
 	mv "$upstream/hooks/pre-receive" "$upstream/hooks/pre-receive.ok" &&
-	write_script "$upstream/hooks/pre-receive" <<-EOF
+	test_hook -C "$upstream" --clobber pre-receive <<-\EOF
 	exit 1
 	EOF
 '
diff --git a/t/t5411/test-0013-bad-protocol.sh b/t/t5411/test-0013-bad-protocol.sh
index c08a00ded2..8d22e17aee 100644
--- a/t/t5411/test-0013-bad-protocol.sh
+++ b/t/t5411/test-0013-bad-protocol.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (unknown version, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --version 2
 	EOF
@@ -40,7 +40,7 @@ test_expect_success "proc-receive: bad protocol (unknown version, $PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (hook --die-read-version, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-read-version
 	EOF
@@ -65,13 +65,13 @@ test_expect_success "proc-receive: bad protocol (hook --die-read-version, $PROTO
 	grep "remote: fatal: die with the --die-read-version option" out-$test_count &&
 	grep "remote: error: fail to negotiate version with proc-receive hook" out-$test_count &&
 
-	test_cmp_refs -C "$upstream" <<-EOF
+	test_cmp_refs -C "$upstream" <<-\EOF
 	<COMMIT-A> refs/heads/main
 	EOF
 '
 
 test_expect_success "setup proc-receive hook (hook --die-write-version, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-write-version
 	EOF
@@ -102,7 +102,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-write-version, $PROT
 '
 
 test_expect_success "setup proc-receive hook (hook --die-read-commands, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-read-commands
 	EOF
@@ -132,7 +132,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-read-commands, $PROT
 '
 
 test_expect_success "setup proc-receive hook (hook --die-read-push-options, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-read-push-options
 	EOF
@@ -164,7 +164,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-read-push-options, $
 '
 
 test_expect_success "setup proc-receive hook (hook --die-write-report, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-write-report
 	EOF
@@ -194,7 +194,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-write-report, $PROTO
 '
 
 test_expect_success "setup proc-receive hook (no report, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v
 	EOF
@@ -236,7 +236,7 @@ test_expect_success "cleanup ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (no ref, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok"
@@ -269,7 +269,7 @@ test_expect_success "proc-receive: bad protocol (no ref, $PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (unknown status, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "xx refs/for/main/topic"
diff --git a/t/t5411/test-0014-bad-protocol--porcelain.sh b/t/t5411/test-0014-bad-protocol--porcelain.sh
index 3eaa597e0f..298a3d1fec 100644
--- a/t/t5411/test-0014-bad-protocol--porcelain.sh
+++ b/t/t5411/test-0014-bad-protocol--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (unknown version, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --version 2
 	EOF
@@ -40,7 +40,7 @@ test_expect_success "proc-receive: bad protocol (unknown version, $PROTOCOL/porc
 '
 
 test_expect_success "setup proc-receive hook (hook --die-read-version, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-read-version
 	EOF
@@ -71,7 +71,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-read-version, $PROTO
 '
 
 test_expect_success "setup proc-receive hook (hook --die-write-version, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-write-version
 	EOF
@@ -102,7 +102,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-write-version, $PROT
 '
 
 test_expect_success "setup proc-receive hook (hook --die-read-commands, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-read-commands
 	EOF
@@ -132,7 +132,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-read-commands, $PROT
 '
 
 test_expect_success "setup proc-receive hook (hook --die-read-push-options, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-read-push-options
 	EOF
@@ -164,7 +164,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-read-push-options, $
 '
 
 test_expect_success "setup proc-receive hook (hook --die-write-report, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v --die-write-report
 	EOF
@@ -194,7 +194,7 @@ test_expect_success "proc-receive: bad protocol (hook --die-write-report, $PROTO
 '
 
 test_expect_success "setup proc-receive hook (no report, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v
 	EOF
@@ -236,7 +236,7 @@ test_expect_success "cleanup ($PROTOCOL/porcelain)" '
 '
 
 test_expect_success "setup proc-receive hook (no ref, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok"
@@ -270,7 +270,7 @@ test_expect_success "proc-receive: bad protocol (no ref, $PROTOCOL/porcelain)" '
 '
 
 test_expect_success "setup proc-receive hook (unknown status, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "xx refs/for/main/topic"
diff --git a/t/t5411/test-0020-report-ng.sh b/t/t5411/test-0020-report-ng.sh
index e915dbc28d..6347c9629b 100644
--- a/t/t5411/test-0020-report-ng.sh
+++ b/t/t5411/test-0020-report-ng.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (ng, no message, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ng refs/for/main/topic"
@@ -31,7 +31,7 @@ test_expect_success "proc-receive: fail to update (ng, no message, $PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (ng message, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ng refs/for/main/topic error msg"
diff --git a/t/t5411/test-0021-report-ng--porcelain.sh b/t/t5411/test-0021-report-ng--porcelain.sh
index 2a392e099b..502b34fe3d 100644
--- a/t/t5411/test-0021-report-ng--porcelain.sh
+++ b/t/t5411/test-0021-report-ng--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (ng, no message, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ng refs/for/main/topic"
@@ -32,7 +32,7 @@ test_expect_success "proc-receive: fail to update (ng, no message, $PROTOCOL/por
 '
 
 test_expect_success "setup proc-receive hook (ng message, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ng refs/for/main/topic error msg"
diff --git a/t/t5411/test-0022-report-unexpect-ref.sh b/t/t5411/test-0022-report-unexpect-ref.sh
index f7a494bdb9..7744392a62 100644
--- a/t/t5411/test-0022-report-unexpect-ref.sh
+++ b/t/t5411/test-0022-report-unexpect-ref.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (unexpected ref, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/heads/main"
diff --git a/t/t5411/test-0023-report-unexpect-ref--porcelain.sh b/t/t5411/test-0023-report-unexpect-ref--porcelain.sh
index 63c479e975..6d116ef692 100644
--- a/t/t5411/test-0023-report-unexpect-ref--porcelain.sh
+++ b/t/t5411/test-0023-report-unexpect-ref--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (unexpected ref, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/heads/main"
diff --git a/t/t5411/test-0024-report-unknown-ref.sh b/t/t5411/test-0024-report-unknown-ref.sh
index af055aa086..619ca2f421 100644
--- a/t/t5411/test-0024-report-unknown-ref.sh
+++ b/t/t5411/test-0024-report-unknown-ref.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (unexpected ref, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
diff --git a/t/t5411/test-0025-report-unknown-ref--porcelain.sh b/t/t5411/test-0025-report-unknown-ref--porcelain.sh
index 99601ca321..8b3f5d05a3 100644
--- a/t/t5411/test-0025-report-unknown-ref--porcelain.sh
+++ b/t/t5411/test-0025-report-unknown-ref--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (unexpected ref, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
diff --git a/t/t5411/test-0026-push-options.sh b/t/t5411/test-0026-push-options.sh
index fec5f95793..6dfc7b1c0d 100644
--- a/t/t5411/test-0026-push-options.sh
+++ b/t/t5411/test-0026-push-options.sh
@@ -1,6 +1,6 @@
 test_expect_success "setup proc-receive hook and disable push-options ($PROTOCOL)" '
 	git -C "$upstream" config receive.advertisePushOptions false &&
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
@@ -31,7 +31,7 @@ test_expect_success "enable push options ($PROTOCOL)" '
 '
 
 test_expect_success "setup version=0 for proc-receive hook ($PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		--version 0 \
@@ -75,7 +75,7 @@ test_expect_success "proc-receive: ignore push-options for version 0 ($PROTOCOL)
 '
 
 test_expect_success "restore proc-receive hook ($PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
diff --git a/t/t5411/test-0027-push-options--porcelain.sh b/t/t5411/test-0027-push-options--porcelain.sh
index 8fb75a8789..768880b40f 100644
--- a/t/t5411/test-0027-push-options--porcelain.sh
+++ b/t/t5411/test-0027-push-options--porcelain.sh
@@ -1,6 +1,6 @@
 test_expect_success "setup proc-receive hook and disable push-options ($PROTOCOL/porcelain)" '
 	git -C "$upstream" config receive.advertisePushOptions false &&
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
@@ -32,7 +32,7 @@ test_expect_success "enable push options ($PROTOCOL/porcelain)" '
 '
 
 test_expect_success "setup version=0 for proc-receive hook ($PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		--version 0 \
@@ -78,7 +78,7 @@ test_expect_success "proc-receive: ignore push-options for version 0 ($PROTOCOL/
 '
 
 test_expect_success "restore proc-receive hook ($PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
diff --git a/t/t5411/test-0030-report-ok.sh b/t/t5411/test-0030-report-ok.sh
index a3a6278213..0f190a6e85 100644
--- a/t/t5411/test-0030-report-ok.sh
+++ b/t/t5411/test-0030-report-ok.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (ok, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
diff --git a/t/t5411/test-0031-report-ok--porcelain.sh b/t/t5411/test-0031-report-ok--porcelain.sh
index 0e175388b6..7ec3981263 100644
--- a/t/t5411/test-0031-report-ok--porcelain.sh
+++ b/t/t5411/test-0031-report-ok--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (ok, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic"
diff --git a/t/t5411/test-0032-report-with-options.sh b/t/t5411/test-0032-report-with-options.sh
index 988a4302a6..07733b94b8 100644
--- a/t/t5411/test-0032-report-with-options.sh
+++ b/t/t5411/test-0032-report-with-options.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (option without matching ok, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "option refname refs/pull/123/head" \
@@ -30,7 +30,7 @@ test_expect_success "proc-receive: report option without matching ok ($PROTOCOL)
 '
 
 test_expect_success "setup proc-receive hook (option refname, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -62,7 +62,7 @@ test_expect_success "proc-receive: report option refname ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (option refname and forced-update, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -95,7 +95,7 @@ test_expect_success "proc-receive: report option refname and forced-update ($PRO
 '
 
 test_expect_success "setup proc-receive hook (option refname and old-oid, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -129,7 +129,7 @@ test_expect_success "proc-receive: report option refname and old-oid ($PROTOCOL)
 '
 
 test_expect_success "setup proc-receive hook (option old-oid, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -161,7 +161,7 @@ test_expect_success "proc-receive: report option old-oid ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (option old-oid and new-oid, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -195,7 +195,7 @@ test_expect_success "proc-receive: report option old-oid and new-oid ($PROTOCOL)
 '
 
 test_expect_success "setup proc-receive hook (report with multiple rewrites, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/a/b/c/topic" \
diff --git a/t/t5411/test-0033-report-with-options--porcelain.sh b/t/t5411/test-0033-report-with-options--porcelain.sh
index daacb3d69d..2e1831b104 100644
--- a/t/t5411/test-0033-report-with-options--porcelain.sh
+++ b/t/t5411/test-0033-report-with-options--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (option without matching ok, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "option refname refs/pull/123/head" \
@@ -31,7 +31,7 @@ test_expect_success "proc-receive: report option without matching ok ($PROTOCOL/
 '
 
 test_expect_success "setup proc-receive hook (option refname, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -64,7 +64,7 @@ test_expect_success "proc-receive: report option refname ($PROTOCOL/porcelain)"
 '
 
 test_expect_success "setup proc-receive hook (option refname and forced-update, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -99,7 +99,7 @@ test_expect_success "proc-receive: report option refname and forced-update ($PRO
 '
 
 test_expect_success "setup proc-receive hook (option refname and old-oid, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -134,7 +134,7 @@ test_expect_success "proc-receive: report option refname and old-oid ($PROTOCOL/
 '
 
 test_expect_success "setup proc-receive hook (option old-oid, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -167,7 +167,7 @@ test_expect_success "proc-receive: report option old-oid ($PROTOCOL/porcelain)"
 '
 
 test_expect_success "setup proc-receive hook (option old-oid and new-oid, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -202,7 +202,7 @@ test_expect_success "proc-receive: report option old-oid and new-oid ($PROTOCOL/
 '
 
 test_expect_success "setup proc-receive hook (report with multiple rewrites, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/a/b/c/topic" \
diff --git a/t/t5411/test-0034-report-ft.sh b/t/t5411/test-0034-report-ft.sh
index 73a47d1ffd..0e37535065 100644
--- a/t/t5411/test-0034-report-ft.sh
+++ b/t/t5411/test-0034-report-ft.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (ft, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
diff --git a/t/t5411/test-0035-report-ft--porcelain.sh b/t/t5411/test-0035-report-ft--porcelain.sh
index c350201107..b9a05181f1 100644
--- a/t/t5411/test-0035-report-ft--porcelain.sh
+++ b/t/t5411/test-0035-report-ft--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (fall-through, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-\EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
diff --git a/t/t5411/test-0036-report-multi-rewrite-for-one-ref.sh b/t/t5411/test-0036-report-multi-rewrite-for-one-ref.sh
index 8c8a6c16e1..889e97057b 100644
--- a/t/t5411/test-0036-report-multi-rewrite-for-one-ref.sh
+++ b/t/t5411/test-0036-report-multi-rewrite-for-one-ref.sh
@@ -14,7 +14,7 @@ test_expect_success "setup git config for remote-tracking of special refs" '
 '
 
 test_expect_success "setup proc-receive hook (multiple rewrites for one ref, no refname for the 1st rewrite, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -87,7 +87,7 @@ test_expect_success "proc-receive: check remote-tracking #1 ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (multiple rewrites for one ref, no refname for the 2nd rewrite, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -162,7 +162,7 @@ test_expect_success "proc-receive: check remote-tracking #2 ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook (multiple rewrites for one ref, $PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
diff --git a/t/t5411/test-0037-report-multi-rewrite-for-one-ref--porcelain.sh b/t/t5411/test-0037-report-multi-rewrite-for-one-ref--porcelain.sh
index bc44810f33..1e523b1c17 100644
--- a/t/t5411/test-0037-report-multi-rewrite-for-one-ref--porcelain.sh
+++ b/t/t5411/test-0037-report-multi-rewrite-for-one-ref--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook (multiple rewrites for one ref, no refname for the 1st rewrite, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -58,7 +58,7 @@ test_expect_success "proc-receive: multiple rewrite for one ref, no refname for
 '
 
 test_expect_success "setup proc-receive hook (multiple rewrites for one ref, no refname for the 2nd rewrite, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
@@ -119,7 +119,7 @@ test_expect_success "proc-receive: multiple rewrites for one ref, no refname for
 '
 
 test_expect_success "setup proc-receive hook (multiple rewrites for one ref, $PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/main/topic" \
diff --git a/t/t5411/test-0038-report-mixed-refs.sh b/t/t5411/test-0038-report-mixed-refs.sh
index e63fe7ba11..4c70e84e41 100644
--- a/t/t5411/test-0038-report-mixed-refs.sh
+++ b/t/t5411/test-0038-report-mixed-refs.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook ($PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/next/topic2" \
diff --git a/t/t5411/test-0039-report-mixed-refs--porcelain.sh b/t/t5411/test-0039-report-mixed-refs--porcelain.sh
index 99d17b73af..40f4c5b1af 100644
--- a/t/t5411/test-0039-report-mixed-refs--porcelain.sh
+++ b/t/t5411/test-0039-report-mixed-refs--porcelain.sh
@@ -1,5 +1,5 @@
 test_expect_success "setup proc-receive hook ($PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/for/next/topic2" \
diff --git a/t/t5411/test-0040-process-all-refs.sh b/t/t5411/test-0040-process-all-refs.sh
index 2f405adefa..7ae3851efb 100644
--- a/t/t5411/test-0040-process-all-refs.sh
+++ b/t/t5411/test-0040-process-all-refs.sh
@@ -17,7 +17,7 @@ test_expect_success "setup upstream branches ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook ($PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/heads/main" \
diff --git a/t/t5411/test-0041-process-all-refs--porcelain.sh b/t/t5411/test-0041-process-all-refs--porcelain.sh
index c88405792e..02e1e084d6 100644
--- a/t/t5411/test-0041-process-all-refs--porcelain.sh
+++ b/t/t5411/test-0041-process-all-refs--porcelain.sh
@@ -17,7 +17,7 @@ test_expect_success "setup upstream branches ($PROTOCOL/porcelain)" '
 '
 
 test_expect_success "setup proc-receive hook ($PROTOCOL/porcelain)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/heads/main" \
diff --git a/t/t5411/test-0050-proc-receive-refs-with-modifiers.sh b/t/t5411/test-0050-proc-receive-refs-with-modifiers.sh
index 31989f0185..7efdfe5598 100644
--- a/t/t5411/test-0050-proc-receive-refs-with-modifiers.sh
+++ b/t/t5411/test-0050-proc-receive-refs-with-modifiers.sh
@@ -9,7 +9,7 @@ test_expect_success "config receive.procReceiveRefs with modifiers ($PROTOCOL)"
 '
 
 test_expect_success "setup proc-receive hook ($PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/heads/main" \
@@ -70,7 +70,7 @@ test_expect_success "setup upstream: create tags/v123 ($PROTOCOL)" '
 '
 
 test_expect_success "setup proc-receive hook ($PROTOCOL)" '
-	write_script "$upstream/hooks/proc-receive" <<-EOF
+	test_hook -C "$upstream" --clobber proc-receive <<-EOF
 	printf >&2 "# proc-receive hook\n"
 	test-tool proc-receive -v \
 		-r "ok refs/heads/main" \
diff --git a/t/t5503-tagfollow.sh b/t/t5503-tagfollow.sh
index a3c01014b7..acdb731edf 100755
--- a/t/t5503-tagfollow.sh
+++ b/t/t5503-tagfollow.sh
@@ -169,7 +169,7 @@ test_expect_success 'atomic fetch with failing backfill' '
 	# one of both fails to update correctly.
 	#
 	# To trigger failure we simply abort when backfilling a tag.
-	write_script clone3/.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C clone3 reference-transaction <<-\EOF &&
 		while read oldrev newrev reference
 		do
 			if test "$reference" = refs/tags/tag1
@@ -201,7 +201,7 @@ test_expect_success 'atomic fetch with backfill should use single transaction' '
 		$ZERO_OID $T refs/tags/tag1
 	EOF
 
-	write_script clone4/.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C clone4 reference-transaction <<-\EOF &&
 		( echo "$*" && cat ) >>actual
 	EOF
 
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 48e14e2dab..6f38a69fbb 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -273,7 +273,7 @@ test_expect_success 'fetch --atomic executes a single reference transaction only
 	EOF
 
 	rm -f atomic/actual &&
-	write_script atomic/.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C atomic reference-transaction <<-\EOF &&
 		( echo "$*" && cat ) >>actual
 	EOF
 
@@ -306,7 +306,7 @@ test_expect_success 'fetch --atomic aborts all reference updates if hook aborts'
 	EOF
 
 	rm -f atomic/actual &&
-	write_script atomic/.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C atomic/.git reference-transaction <<-\EOF &&
 		( echo "$*" && cat ) >>actual
 		exit 1
 	EOF
@@ -334,7 +334,7 @@ test_expect_success 'fetch --atomic --append appends to FETCH_HEAD' '
 	test_line_count = 2 atomic/.git/FETCH_HEAD &&
 	cp atomic/.git/FETCH_HEAD expected &&
 
-	write_script atomic/.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C atomic reference-transaction <<-\EOF &&
 		exit 1
 	EOF
 
@@ -364,7 +364,7 @@ test_expect_success 'fetch --atomic --prune executes a single reference transact
 		$ZERO_OID $head_oid refs/remotes/origin/new-branch
 	EOF
 
-	write_script atomic/.git/hooks/reference-transaction <<-\EOF &&
+	test_hook -C atomic reference-transaction <<-\EOF &&
 		( echo "$*" && cat ) >>actual
 	EOF
 
diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh
index 3137eb8d4d..4dfb080433 100755
--- a/t/t5516-fetch-push.sh
+++ b/t/t5516-fetch-push.sh
@@ -23,14 +23,10 @@ D=$(pwd)
 
 mk_empty () {
 	repo_name="$1"
-	rm -fr "$repo_name" &&
-	mkdir "$repo_name" &&
-	(
-		cd "$repo_name" &&
-		git init &&
-		git config receive.denyCurrentBranch warn &&
-		mv .git/hooks .git/hooks-disabled
-	)
+	test_when_finished "rm -rf \"$repo_name\"" &&
+	test_path_is_missing "$repo_name" &&
+	git init "$repo_name" &&
+	git -C "$repo_name" config receive.denyCurrentBranch warn
 }
 
 mk_test () {
@@ -59,40 +55,28 @@ mk_test () {
 mk_test_with_hooks() {
 	repo_name=$1
 	mk_test "$@" &&
-	(
-		cd "$repo_name" &&
-		mkdir .git/hooks &&
-		cd .git/hooks &&
-
-		cat >pre-receive <<-'EOF' &&
-		#!/bin/sh
-		cat - >>pre-receive.actual
-		EOF
-
-		cat >update <<-'EOF' &&
-		#!/bin/sh
-		printf "%s %s %s\n" "$@" >>update.actual
-		EOF
-
-		cat >post-receive <<-'EOF' &&
-		#!/bin/sh
-		cat - >>post-receive.actual
-		EOF
-
-		cat >post-update <<-'EOF' &&
-		#!/bin/sh
-		for ref in "$@"
-		do
-			printf "%s\n" "$ref" >>post-update.actual
-		done
-		EOF
-
-		chmod +x pre-receive update post-receive post-update
-	)
+	test_hook -C "$repo_name" pre-receive <<-'EOF' &&
+	cat - >>pre-receive.actual
+	EOF
+
+	test_hook -C "$repo_name" update <<-'EOF' &&
+	printf "%s %s %s\n" "$@" >>update.actual
+	EOF
+
+	test_hook -C "$repo_name" post-receive <<-'EOF' &&
+	cat - >>post-receive.actual
+	EOF
+
+	test_hook -C "$repo_name" post-update <<-'EOF'
+	for ref in "$@"
+	do
+		printf "%s\n" "$ref" >>post-update.actual
+	done
+	EOF
 }
 
 mk_child() {
-	rm -rf "$2" &&
+	test_when_finished "rm -rf \"$2\"" &&
 	git clone "$1" "$2"
 }
 
@@ -197,32 +181,32 @@ grep_wrote () {
 	grep 'write_pack_file/wrote.*"value":"'$1'"' $2
 }
 
-test_expect_success 'push with negotiation' '
-	# Without negotiation
+test_expect_success 'push without negotiation' '
 	mk_empty testrepo &&
 	git push testrepo $the_first_commit:refs/remotes/origin/first_commit &&
 	test_commit -C testrepo unrelated_commit &&
 	git -C testrepo config receive.hideRefs refs/remotes/origin/first_commit &&
-	echo now pushing without negotiation &&
+	test_when_finished "rm event" &&
 	GIT_TRACE2_EVENT="$(pwd)/event" git -c protocol.version=2 push testrepo refs/heads/main:refs/remotes/origin/main &&
-	grep_wrote 5 event && # 2 commits, 2 trees, 1 blob
+	grep_wrote 5 event # 2 commits, 2 trees, 1 blob
+'
 
-	# Same commands, but with negotiation
-	rm event &&
+test_expect_success 'push with negotiation' '
 	mk_empty testrepo &&
 	git push testrepo $the_first_commit:refs/remotes/origin/first_commit &&
 	test_commit -C testrepo unrelated_commit &&
 	git -C testrepo config receive.hideRefs refs/remotes/origin/first_commit &&
+	test_when_finished "rm event" &&
 	GIT_TRACE2_EVENT="$(pwd)/event" git -c protocol.version=2 -c push.negotiate=1 push testrepo refs/heads/main:refs/remotes/origin/main &&
 	grep_wrote 2 event # 1 commit, 1 tree
 '
 
 test_expect_success 'push with negotiation proceeds anyway even if negotiation fails' '
-	rm event &&
 	mk_empty testrepo &&
 	git push testrepo $the_first_commit:refs/remotes/origin/first_commit &&
 	test_commit -C testrepo unrelated_commit &&
 	git -C testrepo config receive.hideRefs refs/remotes/origin/first_commit &&
+	test_when_finished "rm event" &&
 	GIT_TEST_PROTOCOL_VERSION=0 GIT_TRACE2_EVENT="$(pwd)/event" \
 		git -c push.negotiate=1 push testrepo refs/heads/main:refs/remotes/origin/main 2>err &&
 	grep_wrote 5 event && # 2 commits, 2 trees, 1 blob
@@ -668,7 +652,6 @@ test_expect_success 'push does not update local refs on failure' '
 
 	mk_test testrepo heads/main &&
 	mk_child testrepo child &&
-	mkdir testrepo/.git/hooks &&
 	echo "#!/no/frobnication/today" >testrepo/.git/hooks/pre-receive &&
 	chmod +x testrepo/.git/hooks/pre-receive &&
 	(
@@ -1330,7 +1313,7 @@ done
 
 test_expect_success 'fetch follows tags by default' '
 	mk_test testrepo heads/main &&
-	rm -fr src dst &&
+	test_when_finished "rm -rf src" &&
 	git init src &&
 	(
 		cd src &&
@@ -1340,6 +1323,7 @@ test_expect_success 'fetch follows tags by default' '
 		sed -n "p; s|refs/heads/main$|refs/remotes/origin/main|p" tmp1 |
 		sort -k 3 >../expect
 	) &&
+	test_when_finished "rm -rf dst" &&
 	git init dst &&
 	(
 		cd dst &&
@@ -1365,8 +1349,9 @@ test_expect_success 'peeled advertisements are not considered ref tips' '
 
 test_expect_success 'pushing a specific ref applies remote.$name.push as refmap' '
 	mk_test testrepo heads/main &&
-	rm -fr src dst &&
+	test_when_finished "rm -rf src" &&
 	git init src &&
+	test_when_finished "rm -rf dst" &&
 	git init --bare dst &&
 	(
 		cd src &&
@@ -1389,8 +1374,9 @@ test_expect_success 'pushing a specific ref applies remote.$name.push as refmap'
 
 test_expect_success 'with no remote.$name.push, it is not used as refmap' '
 	mk_test testrepo heads/main &&
-	rm -fr src dst &&
+	test_when_finished "rm -rf src" &&
 	git init src &&
+	test_when_finished "rm -rf dst" &&
 	git init --bare dst &&
 	(
 		cd src &&
@@ -1411,8 +1397,9 @@ test_expect_success 'with no remote.$name.push, it is not used as refmap' '
 
 test_expect_success 'with no remote.$name.push, upstream mapping is used' '
 	mk_test testrepo heads/main &&
-	rm -fr src dst &&
+	test_when_finished "rm -rf src" &&
 	git init src &&
+	test_when_finished "rm -rf dst" &&
 	git init --bare dst &&
 	(
 		cd src &&
@@ -1440,8 +1427,9 @@ test_expect_success 'with no remote.$name.push, upstream mapping is used' '
 
 test_expect_success 'push does not follow tags by default' '
 	mk_test testrepo heads/main &&
-	rm -fr src dst &&
+	test_when_finished "rm -rf src" &&
 	git init src &&
+	test_when_finished "rm -rf dst" &&
 	git init --bare dst &&
 	(
 		cd src &&
@@ -1463,8 +1451,9 @@ test_expect_success 'push does not follow tags by default' '
 
 test_expect_success 'push --follow-tags only pushes relevant tags' '
 	mk_test testrepo heads/main &&
-	rm -fr src dst &&
+	test_when_finished "rm -rf src" &&
 	git init src &&
+	test_when_finished "rm -rf dst" &&
 	git init --bare dst &&
 	(
 		cd src &&
@@ -1502,9 +1491,9 @@ EOF
 '
 
 test_expect_success 'pushing a tag pushes the tagged object' '
-	rm -rf dst.git &&
 	blob=$(echo unreferenced | git hash-object -w --stdin) &&
 	git tag -m foo tag-of-blob $blob &&
+	test_when_finished "rm -rf dst.git" &&
 	git init --bare dst.git &&
 	git push dst.git tag-of-blob &&
 	# the receiving index-pack should have noticed
@@ -1515,7 +1504,7 @@ test_expect_success 'pushing a tag pushes the tagged object' '
 '
 
 test_expect_success 'push into bare respects core.logallrefupdates' '
-	rm -rf dst.git &&
+	test_when_finished "rm -rf dst.git" &&
 	git init --bare dst.git &&
 	git -C dst.git config core.logallrefupdates true &&
 
@@ -1533,7 +1522,7 @@ test_expect_success 'push into bare respects core.logallrefupdates' '
 '
 
 test_expect_success 'fetch into bare respects core.logallrefupdates' '
-	rm -rf dst.git &&
+	test_when_finished "rm -rf dst.git" &&
 	git init --bare dst.git &&
 	(
 		cd dst.git &&
@@ -1554,6 +1543,7 @@ test_expect_success 'fetch into bare respects core.logallrefupdates' '
 '
 
 test_expect_success 'receive.denyCurrentBranch = updateInstead' '
+	mk_empty testrepo &&
 	git push testrepo main &&
 	(
 		cd testrepo &&
@@ -1656,7 +1646,7 @@ test_expect_success 'receive.denyCurrentBranch = updateInstead' '
 	) &&
 
 	# (5) push into void
-	rm -fr void &&
+	test_when_finished "rm -rf void" &&
 	git init void &&
 	(
 		cd void &&
@@ -1678,26 +1668,23 @@ test_expect_success 'receive.denyCurrentBranch = updateInstead' '
 '
 
 test_expect_success 'updateInstead with push-to-checkout hook' '
-	rm -fr testrepo &&
+	test_when_finished "rm -rf testrepo" &&
 	git init testrepo &&
-	(
-		cd testrepo &&
-		git pull .. main &&
-		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
-	) &&
+	git -C testrepo pull .. main &&
+	git -C testrepo reset --hard HEAD^^ &&
+	git -C testrepo tag initial &&
+	git -C testrepo config receive.denyCurrentBranch updateInstead &&
+	test_hook -C testrepo 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 main &&
@@ -1740,35 +1727,32 @@ test_expect_success 'updateInstead with push-to-checkout hook' '
 	) &&
 
 	# push into void
-	rm -fr void &&
+	test_when_finished "rm -rf void" &&
 	git init void &&
-	(
-		cd void &&
-		git config receive.denyCurrentBranch updateInstead &&
-		write_script .git/hooks/push-to-checkout <<-\EOF
-		if git rev-parse --quiet --verify HEAD
-		then
-			has_head=yes
-			echo >&2 updating from $(git rev-parse HEAD)
-		else
-			has_head=no
-			echo >&2 pushing into void
-		fi
-		echo >&2 updating to "$1"
-
-		git update-index -q --refresh &&
-		case "$has_head" in
-		yes)
-			git read-tree -u -m HEAD "$1" ;;
-		no)
-			git read-tree -u -m "$1" ;;
-		esac || {
-			status=$?
-			echo >&2 read-tree failed
-			exit $status
-		}
-		EOF
-	) &&
+	git -C void config receive.denyCurrentBranch updateInstead &&
+	test_hook -C void push-to-checkout <<-\EOF &&
+	if git rev-parse --quiet --verify HEAD
+	then
+		has_head=yes
+		echo >&2 updating from $(git rev-parse HEAD)
+	else
+		has_head=no
+		echo >&2 pushing into void
+	fi
+	echo >&2 updating to "$1"
+
+	git update-index -q --refresh &&
+	case "$has_head" in
+	yes)
+		git read-tree -u -m HEAD "$1" ;;
+	no)
+		git read-tree -u -m "$1" ;;
+	esac || {
+		status=$?
+		echo >&2 read-tree failed
+		exit $status
+	}
+	EOF
 
 	git push void main &&
 	(
diff --git a/t/t5521-pull-options.sh b/t/t5521-pull-options.sh
index 66cfcb09c5..264de29c35 100755
--- a/t/t5521-pull-options.sh
+++ b/t/t5521-pull-options.sh
@@ -233,7 +233,7 @@ test_expect_success 'git pull --no-verify flag passed to merge' '
 	git init src &&
 	test_commit -C src one &&
 	git clone src dst &&
-	write_script dst/.git/hooks/commit-msg <<-\EOF &&
+	test_hook -C dst commit-msg <<-\EOF &&
 	false
 	EOF
 	test_commit -C src two &&
@@ -245,7 +245,7 @@ test_expect_success 'git pull --no-verify --verify passed to merge' '
 	git init src &&
 	test_commit -C src one &&
 	git clone src dst &&
-	write_script dst/.git/hooks/commit-msg <<-\EOF &&
+	test_hook -C dst commit-msg <<-\EOF &&
 	false
 	EOF
 	test_commit -C src two &&
diff --git a/t/t5534-push-signed.sh b/t/t5534-push-signed.sh
index 24d374adba..7c0a148e73 100755
--- a/t/t5534-push-signed.sh
+++ b/t/t5534-push-signed.sh
@@ -35,8 +35,7 @@ test_expect_success setup '
 
 test_expect_success 'unsigned push does not send push certificate' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
@@ -52,8 +51,7 @@ test_expect_success 'unsigned push does not send push certificate' '
 
 test_expect_success 'talking with a receiver without push certificate support' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
@@ -69,22 +67,19 @@ test_expect_success 'talking with a receiver without push certificate support' '
 
 test_expect_success 'push --signed fails with a receiver without push certificate support' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	test_must_fail git push --signed dst noop ff +noff 2>err &&
 	test_i18ngrep "the receiving end does not support" err
 '
 
 test_expect_success 'push --signed=1 is accepted' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	test_must_fail git push --signed=1 dst noop ff +noff 2>err &&
 	test_i18ngrep "the receiving end does not support" err
 '
 
 test_expect_success GPG 'no certificate for a signed push with no update' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	if test -n "${GIT_PUSH_CERT-}"
 	then
 		git cat-file blob $GIT_PUSH_CERT >../push-cert
@@ -96,9 +91,8 @@ test_expect_success GPG 'no certificate for a signed push with no update' '
 
 test_expect_success GPG 'signed push sends push certificate' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	git -C dst config receive.certnonceseed sekrit &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
@@ -139,10 +133,9 @@ test_expect_success GPG 'signed push sends push certificate' '
 
 test_expect_success GPGSSH 'ssh signed push sends push certificate' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	git -C dst config gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
 	git -C dst config receive.certnonceseed sekrit &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
@@ -223,9 +216,8 @@ test_expect_success GPG 'inconsistent push options in signed push not allowed' '
 
 test_expect_success GPG 'fail without key and heed user.signingkey' '
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	git -C dst config receive.certnonceseed sekrit &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
@@ -273,9 +265,8 @@ test_expect_success GPG 'fail without key and heed user.signingkey' '
 test_expect_success GPGSM 'fail without key and heed user.signingkey x509' '
 	test_config gpg.format x509 &&
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	git -C dst config receive.certnonceseed sekrit &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
@@ -326,10 +317,9 @@ test_expect_success GPGSM 'fail without key and heed user.signingkey x509' '
 test_expect_success GPGSSH 'fail without key and heed user.signingkey ssh' '
 	test_config gpg.format ssh &&
 	prepare_dst &&
-	mkdir -p dst/.git/hooks &&
 	git -C dst config gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
 	git -C dst config receive.certnonceseed sekrit &&
-	write_script dst/.git/hooks/post-receive <<-\EOF &&
+	test_hook -C dst post-receive <<-\EOF &&
 	# discard the update list
 	cat >/dev/null
 	# record the push certificate
diff --git a/t/t5540-http-push-webdav.sh b/t/t5540-http-push-webdav.sh
index b0dbacf0b9..37db3dec0c 100755
--- a/t/t5540-http-push-webdav.sh
+++ b/t/t5540-http-push-webdav.sh
@@ -42,7 +42,9 @@ test_expect_success 'setup remote repository' '
 	git clone --bare test_repo test_repo.git &&
 	cd test_repo.git &&
 	git --bare update-server-info &&
-	mv hooks/post-update.sample hooks/post-update &&
+	test_hook --setup post-update <<-\EOF &&
+	exec git update-server-info
+	EOF
 	ORIG_HEAD=$(git rev-parse --verify HEAD) &&
 	cd - &&
 	mv test_repo.git "$HTTPD_DOCUMENT_ROOT_PATH"
diff --git a/t/t5541-http-push-smart.sh b/t/t5541-http-push-smart.sh
index 8ca50f8b18..2f09ff4fac 100755
--- a/t/t5541-http-push-smart.sh
+++ b/t/t5541-http-push-smart.sh
@@ -96,18 +96,18 @@ test_expect_success 'create and delete remote branch' '
 	test_must_fail git show-ref --verify refs/remotes/origin/dev
 '
 
-cat >"$HTTPD_DOCUMENT_ROOT_PATH/test_repo.git/hooks/update" <<EOF
-#!/bin/sh
-exit 1
-EOF
-chmod a+x "$HTTPD_DOCUMENT_ROOT_PATH/test_repo.git/hooks/update"
+test_expect_success 'setup rejected update hook' '
+	test_hook --setup -C "$HTTPD_DOCUMENT_ROOT_PATH/test_repo.git" update <<-\EOF &&
+	exit 1
+	EOF
 
-cat >exp <<EOF
-remote: error: hook declined to update refs/heads/dev2
-To http://127.0.0.1:$LIB_HTTPD_PORT/smart/test_repo.git
- ! [remote rejected] dev2 -> dev2 (hook declined)
-error: failed to push some refs to 'http://127.0.0.1:$LIB_HTTPD_PORT/smart/test_repo.git'
-EOF
+	cat >exp <<-EOF
+	remote: error: hook declined to update refs/heads/dev2
+	To http://127.0.0.1:$LIB_HTTPD_PORT/smart/test_repo.git
+	 ! [remote rejected] dev2 -> dev2 (hook declined)
+	error: failed to push some refs to '\''http://127.0.0.1:$LIB_HTTPD_PORT/smart/test_repo.git'\''
+	EOF
+'
 
 test_expect_success 'rejected update prints status' '
 	cd "$ROOT_PATH"/test_repo_clone &&
@@ -419,10 +419,7 @@ test_expect_success CMDLINE_LIMIT 'push 2000 tags over http' '
 '
 
 test_expect_success GPG 'push with post-receive to inspect certificate' '
-	(
-		cd "$HTTPD_DOCUMENT_ROOT_PATH"/test_repo.git &&
-		mkdir -p hooks &&
-		write_script hooks/post-receive <<-\EOF &&
+	test_hook -C "$HTTPD_DOCUMENT_ROOT_PATH"/test_repo.git post-receive <<-\EOF &&
 		# discard the update list
 		cat >/dev/null
 		# record the push certificate
@@ -437,8 +434,9 @@ test_expect_success GPG 'push with post-receive to inspect certificate' '
 		NONCE_STATUS=${GIT_PUSH_CERT_NONCE_STATUS-nononcestatus}
 		NONCE=${GIT_PUSH_CERT_NONCE-nononce}
 		E_O_F
-		EOF
-
+	EOF
+	(
+		cd "$HTTPD_DOCUMENT_ROOT_PATH"/test_repo.git &&
 		git config receive.certnonceseed sekrit &&
 		git config receive.certnonceslop 30
 	) &&
diff --git a/t/t5543-atomic-push.sh b/t/t5543-atomic-push.sh
index bfee461861..70431122a4 100755
--- a/t/t5543-atomic-push.sh
+++ b/t/t5543-atomic-push.sh
@@ -162,16 +162,10 @@ test_expect_success 'atomic push obeys update hook preventing a branch to be pus
 		test_commit two &&
 		git push --mirror up
 	) &&
-	(
-		cd upstream &&
-		HOOKDIR="$(git rev-parse --git-dir)/hooks" &&
-		HOOK="$HOOKDIR/update" &&
-		mkdir -p "$HOOKDIR" &&
-		write_script "$HOOK" <<-\EOF
-			# only allow update to main from now on
-			test "$1" = "refs/heads/main"
-		EOF
-	) &&
+	test_hook -C upstream update <<-\EOF &&
+	# only allow update to main from now on
+	test "$1" = "refs/heads/main"
+	EOF
 	(
 		cd workbench &&
 		git checkout main &&
diff --git a/t/t5547-push-quarantine.sh b/t/t5547-push-quarantine.sh
index faaa51ccc5..1876fb34e5 100755
--- a/t/t5547-push-quarantine.sh
+++ b/t/t5547-push-quarantine.sh
@@ -5,7 +5,7 @@ test_description='check quarantine of objects during push'
 
 test_expect_success 'create picky dest repo' '
 	git init --bare dest.git &&
-	write_script dest.git/hooks/pre-receive <<-\EOF
+	test_hook --setup -C dest.git pre-receive <<-\EOF
 	while read old new ref; do
 		test "$(git log -1 --format=%s $new)" = reject && exit 1
 	done
@@ -60,7 +60,7 @@ test_expect_success 'push to repo path with path separator (colon)' '
 
 test_expect_success 'updating a ref from quarantine is forbidden' '
 	git init --bare update.git &&
-	write_script update.git/hooks/pre-receive <<-\EOF &&
+	test_hook -C update.git pre-receive <<-\EOF &&
 	read old new refname
 	git update-ref refs/heads/unrelated $new
 	exit 1
diff --git a/t/t5548-push-porcelain.sh b/t/t5548-push-porcelain.sh
index f11ff57e54..6282728eaf 100755
--- a/t/t5548-push-porcelain.sh
+++ b/t/t5548-push-porcelain.sh
@@ -168,7 +168,7 @@ run_git_push_porcelain_output_test() {
 	'
 
 	test_expect_success "prepare pre-receive hook ($PROTOCOL)" '
-		write_script "$upstream/hooks/pre-receive" <<-EOF
+		test_hook --setup -C "$upstream" pre-receive <<-EOF
 		exit 1
 		EOF
 	'
diff --git a/t/t5550-http-fetch-dumb.sh b/t/t5550-http-fetch-dumb.sh
index 259203926a..f0d9cd584d 100755
--- a/t/t5550-http-fetch-dumb.sh
+++ b/t/t5550-http-fetch-dumb.sh
@@ -25,16 +25,17 @@ test_expect_success 'setup repository' '
 	git commit -m two
 '
 
+setup_post_update_server_info_hook () {
+	test_hook --setup -C "$1" post-update <<-\EOF &&
+	exec git update-server-info
+	EOF
+	git -C "$1" update-server-info
+}
+
 test_expect_success 'create http-accessible bare repository with loose objects' '
 	cp -R .git "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
-	(cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
-	 git config core.bare true &&
-	 mkdir -p hooks &&
-	 write_script "hooks/post-update" <<-\EOF &&
-	 exec git update-server-info
-	EOF
-	 hooks/post-update
-	) &&
+	git -C "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" config core.bare true &&
+	setup_post_update_server_info_hook "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
 	git remote add public "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
 	git push public main:main
 '
@@ -62,13 +63,7 @@ test_expect_success 'create password-protected repository' '
 
 test_expect_success 'create empty remote repository' '
 	git init --bare "$HTTPD_DOCUMENT_ROOT_PATH/empty.git" &&
-	(cd "$HTTPD_DOCUMENT_ROOT_PATH/empty.git" &&
-	 mkdir -p hooks &&
-	 write_script "hooks/post-update" <<-\EOF &&
-	 exec git update-server-info
-	EOF
-	 hooks/post-update
-	)
+	setup_post_update_server_info_hook "$HTTPD_DOCUMENT_ROOT_PATH/empty.git"
 '
 
 test_expect_success 'empty dumb HTTP repository has default hash algorithm' '
diff --git a/t/t5571-pre-push-hook.sh b/t/t5571-pre-push-hook.sh
index 96d6ecc0af..a11b20e378 100755
--- a/t/t5571-pre-push-hook.sh
+++ b/t/t5571-pre-push-hook.sh
@@ -6,16 +6,11 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
 
 . ./test-lib.sh
 
-# Setup hook that always succeeds
-HOOKDIR="$(git rev-parse --git-dir)/hooks"
-HOOK="$HOOKDIR/pre-push"
-mkdir -p "$HOOKDIR"
-write_script "$HOOK" <<EOF
-cat >actual
-exit 0
-EOF
-
 test_expect_success 'setup' '
+	test_hook pre-push <<-\EOF &&
+	cat >actual
+	EOF
+
 	git config push.default upstream &&
 	git init --bare repo1 &&
 	git remote add parent1 repo1 &&
@@ -28,15 +23,16 @@ test_expect_success 'setup' '
 	git push parent1 HEAD:foreign &&
 	test_cmp expect actual
 '
-write_script "$HOOK" <<EOF
-cat >actual
-exit 1
-EOF
 
 COMMIT1="$(git rev-parse HEAD)"
 export COMMIT1
 
 test_expect_success 'push with failing hook' '
+	test_hook pre-push <<-\EOF &&
+	cat >actual &&
+	exit 1
+	EOF
+
 	test_commit two &&
 	cat >expect <<-EOF &&
 	HEAD $(git rev-parse HEAD) refs/heads/main $(test_oid zero)
@@ -55,13 +51,13 @@ test_expect_success '--no-verify bypasses hook' '
 COMMIT2="$(git rev-parse HEAD)"
 export COMMIT2
 
-write_script "$HOOK" <<'EOF'
-echo "$1" >actual
-echo "$2" >>actual
-cat >>actual
-EOF
-
 test_expect_success 'push with hook' '
+	test_hook --setup pre-push <<-\EOF &&
+	echo "$1" >actual
+	echo "$2" >>actual
+	cat >>actual
+	EOF
+
 	cat >expect <<-EOF &&
 	parent1
 	repo1
@@ -136,7 +132,9 @@ test_expect_success 'set up many-ref tests' '
 '
 
 test_expect_success 'sigpipe does not cause pre-push hook failure' '
-	echo "exit 0" | write_script "$HOOK" &&
+	test_hook --clobber pre-push <<-\EOF &&
+	exit 0
+	EOF
 	git push parent1 "refs/heads/b/*:refs/heads/b/*"
 '
 
diff --git a/t/t5601-clone.sh b/t/t5601-clone.sh
index 83c24fc97a..4a61f2c901 100755
--- a/t/t5601-clone.sh
+++ b/t/t5601-clone.sh
@@ -79,12 +79,10 @@ test_expect_success 'clone from hooks' '
 	cd .. &&
 	git init r1 &&
 	cd r1 &&
-	cat >.git/hooks/pre-commit <<-\EOF &&
-	#!/bin/sh
+	test_hook pre-commit <<-\EOF &&
 	git clone ../r0 ../r2
 	exit 1
 	EOF
-	chmod u+x .git/hooks/pre-commit &&
 	: >file &&
 	git add file &&
 	test_must_fail git commit -m invoke-hook &&
diff --git a/t/t6500-gc.sh b/t/t6500-gc.sh
index c2021267f2..cd6c53360d 100755
--- a/t/t6500-gc.sh
+++ b/t/t6500-gc.sh
@@ -101,12 +101,12 @@ test_expect_success 'pre-auto-gc hook can stop auto gc' '
 	EOF
 
 	git init pre-auto-gc-hook &&
+	test_hook -C pre-auto-gc-hook pre-auto-gc <<-\EOF &&
+	echo >&2 no gc for you &&
+	exit 1
+	EOF
 	(
 		cd pre-auto-gc-hook &&
-		write_script ".git/hooks/pre-auto-gc" <<-\EOF &&
-		echo >&2 no gc for you &&
-		exit 1
-		EOF
 
 		git config gc.auto 3 &&
 		git config gc.autoDetach false &&
@@ -128,14 +128,12 @@ test_expect_success 'pre-auto-gc hook can stop auto gc' '
 	See "git help gc" for manual housekeeping.
 	EOF
 
-	(
-		cd pre-auto-gc-hook &&
-		write_script ".git/hooks/pre-auto-gc" <<-\EOF &&
-		echo >&2 will gc for you &&
-		exit 0
-		EOF
-		git gc --auto >../out.actual 2>../err.actual
-	) &&
+	test_hook -C pre-auto-gc-hook --clobber pre-auto-gc <<-\EOF &&
+	echo >&2 will gc for you &&
+	exit 0
+	EOF
+
+	git -C pre-auto-gc-hook gc --auto >out.actual 2>err.actual &&
 
 	test_must_be_empty out.actual &&
 	test_cmp err.expect err.actual
diff --git a/t/t7113-post-index-change-hook.sh b/t/t7113-post-index-change-hook.sh
index a21781d68a..58e55a7c77 100755
--- a/t/t7113-post-index-change-hook.sh
+++ b/t/t7113-post-index-change-hook.sh
@@ -17,8 +17,7 @@ test_expect_success 'setup' '
 '
 
 test_expect_success 'test status, add, commit, others trigger hook without flags set' '
-	mkdir -p .git/hooks &&
-	write_script .git/hooks/post-index-change <<-\EOF &&
+	test_hook post-index-change <<-\EOF &&
 		if test "$1" -eq 1; then
 			echo "Invalid combination of flags passed to hook; updated_workdir is set." >testfailure
 			exit 1
@@ -63,7 +62,7 @@ test_expect_success 'test status, add, commit, others trigger hook without flags
 '
 
 test_expect_success 'test checkout and reset trigger the hook' '
-	write_script .git/hooks/post-index-change <<-\EOF &&
+	test_hook post-index-change <<-\EOF &&
 		if test "$1" -eq 1 && test "$2" -eq 1; then
 			echo "Invalid combination of flags passed to hook; updated_workdir and updated_skipworktree are both set." >testfailure
 			exit 1
@@ -106,7 +105,7 @@ test_expect_success 'test checkout and reset trigger the hook' '
 '
 
 test_expect_success 'test reset --mixed and update-index triggers the hook' '
-	write_script .git/hooks/post-index-change <<-\EOF &&
+	test_hook post-index-change <<-\EOF &&
 		if test "$1" -eq 1 && test "$2" -eq 1; then
 			echo "Invalid combination of flags passed to hook; updated_workdir and updated_skipworktree are both set." >testfailure
 			exit 1
diff --git a/t/t7503-pre-commit-and-pre-merge-commit-hooks.sh b/t/t7503-pre-commit-and-pre-merge-commit-hooks.sh
index 606d8d0f08..ad1eb64ba0 100755
--- a/t/t7503-pre-commit-and-pre-merge-commit-hooks.sh
+++ b/t/t7503-pre-commit-and-pre-merge-commit-hooks.sh
@@ -7,37 +7,6 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
 
 . ./test-lib.sh
 
-HOOKDIR="$(git rev-parse --git-dir)/hooks"
-PRECOMMIT="$HOOKDIR/pre-commit"
-PREMERGE="$HOOKDIR/pre-merge-commit"
-
-# Prepare sample scripts that write their $0 to actual_hooks
-test_expect_success 'sample script setup' '
-	mkdir -p "$HOOKDIR" &&
-	write_script "$HOOKDIR/success.sample" <<-\EOF &&
-	echo $0 >>actual_hooks
-	exit 0
-	EOF
-	write_script "$HOOKDIR/fail.sample" <<-\EOF &&
-	echo $0 >>actual_hooks
-	exit 1
-	EOF
-	write_script "$HOOKDIR/non-exec.sample" <<-\EOF &&
-	echo $0 >>actual_hooks
-	exit 1
-	EOF
-	chmod -x "$HOOKDIR/non-exec.sample" &&
-	write_script "$HOOKDIR/require-prefix.sample" <<-\EOF &&
-	echo $0 >>actual_hooks
-	test $GIT_PREFIX = "success/"
-	EOF
-	write_script "$HOOKDIR/check-author.sample" <<-\EOF
-	echo $0 >>actual_hooks
-	test "$GIT_AUTHOR_NAME" = "New Author" &&
-	test "$GIT_AUTHOR_EMAIL" = "newauthor@example.com"
-	EOF
-'
-
 test_expect_success 'root commit' '
 	echo "root" >file &&
 	git add file &&
@@ -96,10 +65,16 @@ test_expect_success '--no-verify with no hook (merge)' '
 	test_path_is_missing actual_hooks
 '
 
+setup_success_hook () {
+	test_when_finished "rm -f actual_hooks expected_hooks" &&
+	echo "$1" >expected_hooks &&
+	test_hook "$1" <<-EOF
+	echo $1 >>actual_hooks
+	EOF
+}
+
 test_expect_success 'with succeeding hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" expected_hooks actual_hooks" &&
-	cp "$HOOKDIR/success.sample" "$PRECOMMIT" &&
-	echo "$PRECOMMIT" >expected_hooks &&
+	setup_success_hook "pre-commit" &&
 	echo "more" >>file &&
 	git add file &&
 	git commit -m "more" &&
@@ -107,9 +82,7 @@ test_expect_success 'with succeeding hook' '
 '
 
 test_expect_success 'with succeeding hook (merge)' '
-	test_when_finished "rm -f \"$PREMERGE\" expected_hooks actual_hooks" &&
-	cp "$HOOKDIR/success.sample" "$PREMERGE" &&
-	echo "$PREMERGE" >expected_hooks &&
+	setup_success_hook "pre-merge-commit" &&
 	git checkout side &&
 	git merge -m "merge main" main &&
 	git checkout main &&
@@ -117,17 +90,14 @@ test_expect_success 'with succeeding hook (merge)' '
 '
 
 test_expect_success 'automatic merge fails; both hooks are available' '
-	test_when_finished "rm -f \"$PREMERGE\" \"$PRECOMMIT\"" &&
-	test_when_finished "rm -f expected_hooks actual_hooks" &&
-	test_when_finished "git checkout main" &&
-	cp "$HOOKDIR/success.sample" "$PREMERGE" &&
-	cp "$HOOKDIR/success.sample" "$PRECOMMIT" &&
+	setup_success_hook "pre-commit" &&
+	setup_success_hook "pre-merge-commit" &&
 
 	git checkout conflicting-a &&
 	test_must_fail git merge -m "merge conflicting-b" conflicting-b &&
 	test_path_is_missing actual_hooks &&
 
-	echo "$PRECOMMIT" >expected_hooks &&
+	echo "pre-commit" >expected_hooks &&
 	echo a+b >conflicting &&
 	git add conflicting &&
 	git commit -m "resolve conflict" &&
@@ -135,8 +105,7 @@ test_expect_success 'automatic merge fails; both hooks are available' '
 '
 
 test_expect_success '--no-verify with succeeding hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" actual_hooks" &&
-	cp "$HOOKDIR/success.sample" "$PRECOMMIT" &&
+	setup_success_hook "pre-commit" &&
 	echo "even more" >>file &&
 	git add file &&
 	git commit --no-verify -m "even more" &&
@@ -144,8 +113,7 @@ test_expect_success '--no-verify with succeeding hook' '
 '
 
 test_expect_success '--no-verify with succeeding hook (merge)' '
-	test_when_finished "rm -f \"$PREMERGE\" actual_hooks" &&
-	cp "$HOOKDIR/success.sample" "$PREMERGE" &&
+	setup_success_hook "pre-merge-commit" &&
 	git branch -f side side-orig &&
 	git checkout side &&
 	git merge --no-verify -m "merge main" main &&
@@ -153,10 +121,19 @@ test_expect_success '--no-verify with succeeding hook (merge)' '
 	test_path_is_missing actual_hooks
 '
 
+setup_failing_hook () {
+	test_when_finished "rm -f actual_hooks" &&
+	test_hook "$1" <<-EOF
+	echo $1-failing-hook >>actual_hooks
+	exit 1
+	EOF
+}
+
 test_expect_success 'with failing hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" expected_hooks actual_hooks" &&
-	cp "$HOOKDIR/fail.sample" "$PRECOMMIT" &&
-	echo "$PRECOMMIT" >expected_hooks &&
+	setup_failing_hook "pre-commit" &&
+	test_when_finished "rm -f expected_hooks" &&
+	echo "pre-commit-failing-hook" >expected_hooks &&
+
 	echo "another" >>file &&
 	git add file &&
 	test_must_fail git commit -m "another" &&
@@ -164,8 +141,7 @@ test_expect_success 'with failing hook' '
 '
 
 test_expect_success '--no-verify with failing hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" actual_hooks" &&
-	cp "$HOOKDIR/fail.sample" "$PRECOMMIT" &&
+	setup_failing_hook "pre-commit" &&
 	echo "stuff" >>file &&
 	git add file &&
 	git commit --no-verify -m "stuff" &&
@@ -173,9 +149,8 @@ test_expect_success '--no-verify with failing hook' '
 '
 
 test_expect_success 'with failing hook (merge)' '
-	test_when_finished "rm -f \"$PREMERGE\" expected_hooks actual_hooks" &&
-	cp "$HOOKDIR/fail.sample" "$PREMERGE" &&
-	echo "$PREMERGE" >expected_hooks &&
+	setup_failing_hook "pre-merge-commit" &&
+	echo "pre-merge-commit-failing-hook" >expected_hooks &&
 	git checkout side &&
 	test_must_fail git merge -m "merge main" main &&
 	git checkout main &&
@@ -183,8 +158,8 @@ test_expect_success 'with failing hook (merge)' '
 '
 
 test_expect_success '--no-verify with failing hook (merge)' '
-	test_when_finished "rm -f \"$PREMERGE\" actual_hooks" &&
-	cp "$HOOKDIR/fail.sample" "$PREMERGE" &&
+	setup_failing_hook "pre-merge-commit" &&
+
 	git branch -f side side-orig &&
 	git checkout side &&
 	git merge --no-verify -m "merge main" main &&
@@ -192,9 +167,18 @@ test_expect_success '--no-verify with failing hook (merge)' '
 	test_path_is_missing actual_hooks
 '
 
+setup_non_exec_hook () {
+	test_when_finished "rm -f actual_hooks" &&
+	test_hook "$1" <<-\EOF &&
+	echo non-exec >>actual_hooks
+	exit 1
+	EOF
+	test_hook --disable "$1"
+}
+
+
 test_expect_success POSIXPERM 'with non-executable hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" actual_hooks" &&
-	cp "$HOOKDIR/non-exec.sample" "$PRECOMMIT" &&
+	setup_non_exec_hook "pre-commit" &&
 	echo "content" >>file &&
 	git add file &&
 	git commit -m "content" &&
@@ -202,8 +186,7 @@ test_expect_success POSIXPERM 'with non-executable hook' '
 '
 
 test_expect_success POSIXPERM '--no-verify with non-executable hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" actual_hooks" &&
-	cp "$HOOKDIR/non-exec.sample" "$PRECOMMIT" &&
+	setup_non_exec_hook "pre-commit" &&
 	echo "more content" >>file &&
 	git add file &&
 	git commit --no-verify -m "more content" &&
@@ -211,8 +194,7 @@ test_expect_success POSIXPERM '--no-verify with non-executable hook' '
 '
 
 test_expect_success POSIXPERM 'with non-executable hook (merge)' '
-	test_when_finished "rm -f \"$PREMERGE\" actual_hooks" &&
-	cp "$HOOKDIR/non-exec.sample" "$PREMERGE" &&
+	setup_non_exec_hook "pre-merge" &&
 	git branch -f side side-orig &&
 	git checkout side &&
 	git merge -m "merge main" main &&
@@ -221,8 +203,7 @@ test_expect_success POSIXPERM 'with non-executable hook (merge)' '
 '
 
 test_expect_success POSIXPERM '--no-verify with non-executable hook (merge)' '
-	test_when_finished "rm -f \"$PREMERGE\" actual_hooks" &&
-	cp "$HOOKDIR/non-exec.sample" "$PREMERGE" &&
+	setup_non_exec_hook "pre-merge" &&
 	git branch -f side side-orig &&
 	git checkout side &&
 	git merge --no-verify -m "merge main" main &&
@@ -230,10 +211,18 @@ test_expect_success POSIXPERM '--no-verify with non-executable hook (merge)' '
 	test_path_is_missing actual_hooks
 '
 
+setup_require_prefix_hook () {
+	test_when_finished "rm -f expected_hooks" &&
+	echo require-prefix >expected_hooks &&
+	test_hook pre-commit <<-\EOF
+	echo require-prefix >>actual_hooks
+	test $GIT_PREFIX = "success/"
+	EOF
+}
+
 test_expect_success 'with hook requiring GIT_PREFIX' '
-	test_when_finished "rm -rf \"$PRECOMMIT\" expected_hooks actual_hooks success" &&
-	cp "$HOOKDIR/require-prefix.sample" "$PRECOMMIT" &&
-	echo "$PRECOMMIT" >expected_hooks &&
+	test_when_finished "rm -rf actual_hooks success" &&
+	setup_require_prefix_hook &&
 	echo "more content" >>file &&
 	git add file &&
 	mkdir success &&
@@ -245,9 +234,8 @@ test_expect_success 'with hook requiring GIT_PREFIX' '
 '
 
 test_expect_success 'with failing hook requiring GIT_PREFIX' '
-	test_when_finished "rm -rf \"$PRECOMMIT\" expected_hooks actual_hooks fail" &&
-	cp "$HOOKDIR/require-prefix.sample" "$PRECOMMIT" &&
-	echo "$PRECOMMIT" >expected_hooks &&
+	test_when_finished "rm -rf actual_hooks fail" &&
+	setup_require_prefix_hook &&
 	echo "more content" >>file &&
 	git add file &&
 	mkdir fail &&
@@ -259,13 +247,23 @@ test_expect_success 'with failing hook requiring GIT_PREFIX' '
 	test_cmp expected_hooks actual_hooks
 '
 
+setup_require_author_hook () {
+	test_when_finished "rm -f expected_hooks actual_hooks" &&
+	echo check-author >expected_hooks &&
+	test_hook pre-commit <<-\EOF
+	echo check-author >>actual_hooks
+	test "$GIT_AUTHOR_NAME" = "New Author" &&
+	test "$GIT_AUTHOR_EMAIL" = "newauthor@example.com"
+	EOF
+}
+
+
 test_expect_success 'check the author in hook' '
-	test_when_finished "rm -f \"$PRECOMMIT\" expected_hooks actual_hooks" &&
-	cp "$HOOKDIR/check-author.sample" "$PRECOMMIT" &&
+	setup_require_author_hook &&
 	cat >expected_hooks <<-EOF &&
-	$PRECOMMIT
-	$PRECOMMIT
-	$PRECOMMIT
+	check-author
+	check-author
+	check-author
 	EOF
 	test_must_fail git commit --allow-empty -m "by a.u.thor" &&
 	(
diff --git a/t/t7504-commit-msg-hook.sh b/t/t7504-commit-msg-hook.sh
index bba58f0480..a39de8c112 100755
--- a/t/t7504-commit-msg-hook.sh
+++ b/t/t7504-commit-msg-hook.sh
@@ -54,15 +54,11 @@ test_expect_success '--no-verify with no hook (editor)' '
 
 '
 
-# now install hook that always succeeds
-HOOKDIR="$(git rev-parse --git-dir)/hooks"
-HOOK="$HOOKDIR/commit-msg"
-mkdir -p "$HOOKDIR"
-cat > "$HOOK" <<EOF
-#!/bin/sh
-exit 0
-EOF
-chmod +x "$HOOK"
+test_expect_success 'setup: commit-msg hook that always succeeds' '
+	test_hook --setup commit-msg <<-\EOF
+	exit 0
+	EOF
+'
 
 test_expect_success 'with succeeding hook' '
 
@@ -98,11 +94,11 @@ test_expect_success '--no-verify with succeeding hook (editor)' '
 
 '
 
-# now a hook that fails
-cat > "$HOOK" <<EOF
-#!/bin/sh
-exit 1
-EOF
+test_expect_success 'setup: commit-msg hook that always fails' '
+	test_hook --clobber commit-msg <<-\EOF
+	exit 1
+	EOF
+'
 
 commit_msg_is () {
 	test "$(git log --pretty=format:%s%b -1)" = "$1"
@@ -176,8 +172,12 @@ test_expect_success 'merge bypasses failing hook with --no-verify' '
 	commit_msg_is "Merge branch '\''main'\'' into newbranch"
 '
 
+test_expect_success 'setup: commit-msg hook made non-executable' '
+	git_dir="$(git rev-parse --git-dir)" &&
+	chmod -x "$git_dir/hooks/commit-msg"
+'
+
 
-chmod -x "$HOOK"
 test_expect_success POSIXPERM 'with non-executable hook' '
 
 	echo "content" >file &&
@@ -212,13 +212,12 @@ test_expect_success POSIXPERM '--no-verify with non-executable hook (editor)' '
 
 '
 
-# now a hook that edits the commit message
-cat > "$HOOK" <<'EOF'
-#!/bin/sh
-echo "new message" > "$1"
-exit 0
-EOF
-chmod +x "$HOOK"
+test_expect_success 'setup: commit-msg hook that edits the commit message' '
+	test_hook --clobber commit-msg <<-\EOF
+	echo "new message" >"$1"
+	exit 0
+	EOF
+'
 
 test_expect_success 'hook edits commit message' '
 
diff --git a/t/t7505-prepare-commit-msg-hook.sh b/t/t7505-prepare-commit-msg-hook.sh
index e39c809ca4..2128142a61 100755
--- a/t/t7505-prepare-commit-msg-hook.sh
+++ b/t/t7505-prepare-commit-msg-hook.sh
@@ -47,25 +47,19 @@ test_expect_success 'with no hook' '
 
 '
 
-# set up fake editor for interactive editing
-cat > fake-editor <<'EOF'
-#!/bin/sh
-exit 0
-EOF
-chmod +x fake-editor
-
-## Not using test_set_editor here so we can easily ensure the editor variable
-## is only set for the editor tests
-FAKE_EDITOR="$(pwd)/fake-editor"
-export FAKE_EDITOR
+test_expect_success 'setup fake editor for interactive editing' '
+	write_script fake-editor <<-\EOF &&
+	exit 0
+	EOF
 
-# now install hook that always succeeds and adds a message
-HOOKDIR="$(git rev-parse --git-dir)/hooks"
-HOOK="$HOOKDIR/prepare-commit-msg"
-mkdir -p "$HOOKDIR"
-echo "#!$SHELL_PATH" > "$HOOK"
-cat >> "$HOOK" <<'EOF'
+	## Not using test_set_editor here so we can easily ensure the editor variable
+	## is only set for the editor tests
+	FAKE_EDITOR="$(pwd)/fake-editor" &&
+	export FAKE_EDITOR
+'
 
+test_expect_success 'setup prepare-commit-msg hook' '
+	test_hook --setup prepare-commit-msg <<\EOF
 GIT_DIR=$(git rev-parse --git-dir)
 if test -d "$GIT_DIR/rebase-merge"
 then
@@ -103,7 +97,7 @@ else
 fi
 exit 0
 EOF
-chmod +x "$HOOK"
+'
 
 echo dummy template > "$(git rev-parse --git-dir)/template"
 
@@ -265,10 +259,11 @@ test_expect_success 'with hook and editor (cherry-pick)' '
 	test "$(git log -1 --pretty=format:%s)" = merge
 '
 
-cat > "$HOOK" <<'EOF'
-#!/bin/sh
-exit 1
-EOF
+test_expect_success 'setup: commit-msg hook that always fails' '
+	test_hook --setup --clobber prepare-commit-msg <<-\EOF
+	exit 1
+	EOF
+'
 
 test_expect_success 'with failing hook' '
 
@@ -296,9 +291,9 @@ test_expect_success 'with failing hook (merge)' '
 	git checkout -B other HEAD@{1} &&
 	echo "more" >> file &&
 	git add file &&
-	rm -f "$HOOK" &&
+	test_hook --remove prepare-commit-msg &&
 	git commit -m other &&
-	write_script "$HOOK" <<-EOF &&
+	test_hook --setup prepare-commit-msg <<-\EOF &&
 	exit 1
 	EOF
 	git checkout - &&
diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh
index fffc57120d..d4f9c6a837 100755
--- a/t/t7519-status-fsmonitor.sh
+++ b/t/t7519-status-fsmonitor.sh
@@ -26,7 +26,7 @@ dirty_repo () {
 }
 
 write_integration_script () {
-	write_script .git/hooks/fsmonitor-test<<-\EOF
+	test_hook --setup --clobber fsmonitor-test<<-\EOF
 	if test "$#" -ne 2
 	then
 		echo "$0: exactly 2 arguments expected"
@@ -56,7 +56,6 @@ test_lazy_prereq UNTRACKED_CACHE '
 '
 
 test_expect_success 'setup' '
-	mkdir -p .git/hooks &&
 	: >tracked &&
 	: >modified &&
 	mkdir dir1 &&
@@ -108,7 +107,7 @@ EOF
 
 # test that "update-index --fsmonitor-valid" sets the fsmonitor valid bit
 test_expect_success 'update-index --fsmonitor-valid" sets the fsmonitor valid bit' '
-	write_script .git/hooks/fsmonitor-test<<-\EOF &&
+	test_hook fsmonitor-test<<-\EOF &&
 		printf "last_update_token\0"
 	EOF
 	git update-index --fsmonitor &&
@@ -169,7 +168,7 @@ EOF
 
 # test that newly added files are marked valid
 test_expect_success 'newly added files are marked valid' '
-	write_script .git/hooks/fsmonitor-test<<-\EOF &&
+	test_hook --setup --clobber fsmonitor-test<<-\EOF &&
 		printf "last_update_token\0"
 	EOF
 	git add new &&
@@ -210,7 +209,7 @@ EOF
 
 # test that *only* files returned by the integration script get flagged as invalid
 test_expect_success '*only* files returned by the integration script get flagged as invalid' '
-	write_script .git/hooks/fsmonitor-test<<-\EOF &&
+	test_hook --clobber fsmonitor-test<<-\EOF &&
 	printf "last_update_token\0"
 	printf "dir1/modified\0"
 	EOF
@@ -231,7 +230,7 @@ test_expect_success 'refresh_index() invalidates fsmonitor cache' '
 	dirty_repo &&
 	write_integration_script &&
 	git add . &&
-	write_script .git/hooks/fsmonitor-test<<-\EOF &&
+	test_hook --clobber fsmonitor-test<<-\EOF &&
 	EOF
 	git commit -m "to reset" &&
 	git reset HEAD~1 &&
@@ -280,7 +279,7 @@ do
 		# Make sure it's actually skipping the check for modified and untracked
 		# (if enabled) files unless it is told about them.
 		test_expect_success "status doesn't detect unreported modifications" '
-			write_script .git/hooks/fsmonitor-test<<-\EOF &&
+			test_hook --clobber fsmonitor-test<<-\EOF &&
 			printf "last_update_token\0"
 			:>marker
 			EOF
@@ -322,7 +321,6 @@ test_expect_success UNTRACKED_CACHE 'ignore .git changes when invalidating UNTR'
 	test_create_repo dot-git &&
 	(
 		cd dot-git &&
-		mkdir -p .git/hooks &&
 		: >tracked &&
 		test-tool chmtime =-60 tracked &&
 		: >modified &&
@@ -414,14 +412,14 @@ test_expect_success 'status succeeds with sparse index' '
 		git -C sparse sparse-checkout init --cone --sparse-index &&
 		git -C sparse sparse-checkout set dir1 dir2 &&
 
-		write_script .git/hooks/fsmonitor-test <<-\EOF &&
+		test_hook --clobber fsmonitor-test <<-\EOF &&
 			printf "last_update_token\0"
 		EOF
 		git -C full config core.fsmonitor ../.git/hooks/fsmonitor-test &&
 		git -C sparse config core.fsmonitor ../.git/hooks/fsmonitor-test &&
 		check_sparse_index_behavior ! &&
 
-		write_script .git/hooks/fsmonitor-test <<-\EOF &&
+		test_hook --clobber fsmonitor-test <<-\EOF &&
 			printf "last_update_token\0"
 			printf "dir1/modified\0"
 		EOF
@@ -439,7 +437,7 @@ test_expect_success 'status succeeds with sparse index' '
 
 		# This one modifies outside the sparse-checkout definition
 		# and hence we expect to expand the sparse-index.
-		write_script .git/hooks/fsmonitor-test <<-\EOF &&
+		test_hook --clobber fsmonitor-test <<-\EOF &&
 			printf "last_update_token\0"
 			printf "dir1a/modified\0"
 		EOF
diff --git a/t/t7520-ignored-hook-warning.sh b/t/t7520-ignored-hook-warning.sh
index 634fb7f23a..dc57526e6f 100755
--- a/t/t7520-ignored-hook-warning.sh
+++ b/t/t7520-ignored-hook-warning.sh
@@ -5,10 +5,7 @@ test_description='ignored hook warning'
 . ./test-lib.sh
 
 test_expect_success setup '
-	hookdir="$(git rev-parse --git-dir)/hooks" &&
-	hook="$hookdir/pre-commit" &&
-	mkdir -p "$hookdir" &&
-	write_script "$hook" <<-\EOF
+	test_hook --setup pre-commit <<-\EOF
 	exit 0
 	EOF
 '
@@ -19,20 +16,20 @@ test_expect_success 'no warning if hook is not ignored' '
 '
 
 test_expect_success POSIXPERM 'warning if hook is ignored' '
-	chmod -x "$hook" &&
+	test_hook --disable pre-commit &&
 	git commit --allow-empty -m "even more" 2>message &&
 	test_i18ngrep -e "hook was ignored" message
 '
 
 test_expect_success POSIXPERM 'no warning if advice.ignoredHook set to false' '
 	test_config advice.ignoredHook false &&
-	chmod -x "$hook" &&
+	test_hook --disable pre-commit &&
 	git commit --allow-empty -m "even more" 2>message &&
 	test_i18ngrep ! -e "hook was ignored" message
 '
 
 test_expect_success 'no warning if unset advice.ignoredHook and hook removed' '
-	rm -f "$hook" &&
+	test_hook --remove pre-commit &&
 	test_unconfig advice.ignoredHook &&
 	git commit --allow-empty -m "even more" 2>message &&
 	test_i18ngrep ! -e "hook was ignored" message
diff --git a/t/t9001-send-email.sh b/t/t9001-send-email.sh
index 84d0f40d76..42694fe584 100755
--- a/t/t9001-send-email.sh
+++ b/t/t9001-send-email.sh
@@ -2288,9 +2288,7 @@ test_expect_success $PREREQ 'cmdline in-reply-to used with --no-thread' '
 '
 
 test_expect_success $PREREQ 'invoke hook' '
-	mkdir -p .git/hooks &&
-
-	write_script .git/hooks/sendemail-validate <<-\EOF &&
+	test_hook sendemail-validate <<-\EOF &&
 	# test that we have the correct environment variable, pwd, and
 	# argument
 	case "$GIT_DIR" in
diff --git a/t/t9800-git-p4-basic.sh b/t/t9800-git-p4-basic.sh
index 806005a793..8b30062c0c 100755
--- a/t/t9800-git-p4-basic.sh
+++ b/t/t9800-git-p4-basic.sh
@@ -277,16 +277,21 @@ test_expect_success 'run hook p4-pre-submit before submit' '
 		git commit -m "add hello.txt" &&
 		git config git-p4.skipSubmitEdit true &&
 		git p4 submit --dry-run >out &&
-		grep "Would apply" out &&
-		mkdir -p .git/hooks &&
-		write_script .git/hooks/p4-pre-submit <<-\EOF &&
-		exit 0
-		EOF
+		grep "Would apply" out
+	) &&
+	test_hook -C "$git" p4-pre-submit <<-\EOF &&
+	exit 0
+	EOF
+	(
+		cd "$git" &&
 		git p4 submit --dry-run >out &&
-		grep "Would apply" out &&
-		write_script .git/hooks/p4-pre-submit <<-\EOF &&
-		exit 1
-		EOF
+		grep "Would apply" out
+	) &&
+	test_hook -C "$git" --clobber p4-pre-submit <<-\EOF &&
+	exit 1
+	EOF
+	(
+		cd "$git" &&
 		test_must_fail git p4 submit --dry-run >errs 2>&1 &&
 		! grep "Would apply" errs
 	)
diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh
index 0f439c99d6..a027f0c409 100644
--- a/t/test-lib-functions.sh
+++ b/t/test-lib-functions.sh
@@ -551,6 +551,82 @@ write_script () {
 	chmod +x "$1"
 }
 
+# Usage: test_hook [options] <hook-name> <<-\EOF
+#
+#   -C <dir>:
+#	Run all git commands in directory <dir>
+#   --setup
+#	Setup a hook for subsequent tests, i.e. don't remove it in a
+#	"test_when_finished"
+#   --clobber
+#	Overwrite an existing <hook-name>, if it exists. Implies
+#	--setup (i.e. the "test_when_finished" is assumed to have been
+#	set up already).
+#    --disable
+#	Disable (chmod -x) an existing <hook-name>, which must exist.
+#    --remove
+#	Remove (rm -f) an existing <hook-name>, which must exist.
+test_hook () {
+	setup= &&
+	clobber= &&
+	disable= &&
+	remove= &&
+	indir= &&
+	while test $# != 0
+	do
+		case "$1" in
+		-C)
+			indir="$2" &&
+			shift
+			;;
+		--setup)
+			setup=t
+			;;
+		--clobber)
+			clobber=t
+			;;
+		--disable)
+			disable=t
+			;;
+		--remove)
+			remove=t
+			;;
+		-*)
+			BUG "invalid argument: $1"
+			;;
+		*)
+			break
+			;;
+		esac &&
+		shift
+	done &&
+
+	git_dir=$(git -C "$indir" rev-parse --absolute-git-dir) &&
+	hook_dir="$git_dir/hooks" &&
+	hook_file="$hook_dir/$1" &&
+	if test -n "$disable$remove"
+	then
+		test_path_is_file "$hook_file" &&
+		if test -n "$disable"
+		then
+			chmod -x "$hook_file"
+		elif test -n "$remove"
+		then
+			rm -f "$hook_file"
+		fi &&
+		return 0
+	fi &&
+	if test -z "$clobber"
+	then
+		test_path_is_missing "$hook_file"
+	fi &&
+	if test -z "$setup$clobber"
+	then
+		test_when_finished "rm \"$hook_file\""
+	fi &&
+	write_script "$hook_file"
+}
+
 # Use test_set_prereq to tell that a particular prerequisite is available.
 # The prerequisite can later be checked for in two ways:
 #