Subject: [PATCH v3] add: support pre-add hook

From: "Chandra Kethi-Reddy via GitGitGadget" <[email-masked]>
Date: Fri, 27 Feb 2026 05:54:52 +0000

From: Chandra Kethi-Reddy <[email-masked]>

"git add" has no hook that lets users inspect what is about to be
staged. Users who want to reject certain paths or content must
wrap the command in a shell alias or wait for pre-commit, which
fires too late to prevent staging.

Introduce a "pre-add" hook that runs after "git add" computes the
new index state but before committing it to disk. The hook
receives two positional arguments:

  $1 -- index path used by this invocation (may not exist yet)
  $2 -- lockfile path containing proposed staged index state

While the lockfile is active the current index path remains readable
and unchanged, so a seperate copy is unnecessary. Hook authors can
inspect the computed result with ordinary tools:

  GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD

without needing to interpret pathspec or mode flags as the proposed
index already reflects their effect.

At the finish label, write_locked_index() writes the proposed index
to the lockfile without COMMIT_LOCK so commit_lock_file() can be
called seperately after the hook runs. However, do_write_locked_index()
unconditionally fires post-index-change after every write, and the
existing test suite (t7113) asserts that index.lock does not exist when
that hook fires. Tying the hook to COMMIT_LOCK would suppress it for
other callers that depend on it after a non-committed write (e.g.,
prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK
flag lets builtin/add.c suppress the automatic notification on just this
call, then emit post-index-change manually after commit_lock_file()
publishes the new index. If the hook rejects, rollback_lock_file()
discards the lockfile and the original index is left unchanged. When
no hook is installed the existing write_locked_index(COMMIT_LOCK |
SKIP_IF_UNCHANGED) path is taken.

The hook gate checks cache_changed regardless of exit_status so that
mixed-result adds (e.g., a tracked modification combined with an
ignored path) still run the hook when index content changes.

The hook is bypassed with "--no-verify" and is not invoked for
--interactive, --patch, --edit, or --dry-run, nor by "git commit -a"
which stages through its own code path.

Signed-off-by: Chandra Kethi-Reddy <[email-masked]>
---
    add: support pre-add hook

    Summary
    =======
    
     * v3 switches pre-add inputs to stable paths ($1 index, $2 lockfile)
       and removes copy-specific tempfile logic
     * v3 fixes mixed-result gating so the hook runs whenever index content
       changed, even if git add returned non-zero
     * v3 adds SKIP_INDEX_CHANGE_HOOK flag to write_locked_index() so that
       post-index-change is not fired while the lockfile is still on disk

    Notes
    =====
    
     * This design intentionally trades ODB prevention for correctness of
       hook inputs: blobs may already be written to object storage when the
       hook runs, but hook rejection still leaves the on-disk index
       unchanged
     * AI Disclosure: Codex and Claude Code CLI were used to assist
       drafting. All tests, code, and docs were committed by hand.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v3
Pull-Request: https://github.com/gitgitgadget/git/pull/2045

Range-diff vs v2:

 1:  10244150e24 ! 1:  d0fb5f9da21 add: support pre-add hook
     @@ Commit message
          "git add" has no hook that lets users inspect what is about to be
          staged. Users who want to reject certain paths or content must
          wrap the command in a shell alias or wait for pre-commit, which
     -    fires after staging is already done and objects may already be in
     -    the object database.
     +    fires too late to prevent staging.
      
          Introduce a "pre-add" hook that runs after "git add" computes the
          new index state but before committing it to disk. The hook
     -    receives two arguments:
     +    receives two positional arguments:
      
     -      $1 -- path to a temporary copy of the index before this "git add"
     -      $2 -- path to the lockfile containing the proposed index
     +      $1 -- index path used by this invocation (may not exist yet)
     +      $2 -- lockfile path containing proposed staged index state
      
     -    $1 on first add can be a non-existent path representing an empty
     -    index.
     -
     -    Hook authors can inspect the computed result with ordinary tools:
     +    While the lockfile is active the current index path remains readable
     +    and unchanged, so a seperate copy is unnecessary. Hook authors can
     +    inspect the computed result with ordinary tools:
      
            GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD
      
     -    without needing to interpret pathspec or mode flags like "-u" or
     -    "--renormalize" -- the proposed index already reflects their effect.
     +    without needing to interpret pathspec or mode flags as the proposed
     +    index already reflects their effect.
     +
     +    At the finish label, write_locked_index() writes the proposed index
     +    to the lockfile without COMMIT_LOCK so commit_lock_file() can be
     +    called seperately after the hook runs. However, do_write_locked_index()
     +    unconditionally fires post-index-change after every write, and the
     +    existing test suite (t7113) asserts that index.lock does not exist when
     +    that hook fires. Tying the hook to COMMIT_LOCK would suppress it for
     +    other callers that depend on it after a non-committed write (e.g.,
     +    prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK
     +    flag lets builtin/add.c suppress the automatic notification on just this
     +    call, then emit post-index-change manually after commit_lock_file()
     +    publishes the new index. If the hook rejects, rollback_lock_file()
     +    discards the lockfile and the original index is left unchanged. When
     +    no hook is installed the existing write_locked_index(COMMIT_LOCK |
     +    SKIP_IF_UNCHANGED) path is taken.
      
     -    The implementation creates a temporary copy of the index via the
     -    tempfile API when find_hook("pre-add") reports a hook is present,
     -    then lets all staging proceed normally. At the finish label,
     -    write_locked_index() writes the proposed index to the lockfile
     -    without COMMIT_LOCK. If the hook approves, commit_lock_file()
     -    atomically replaces the index. If the hook rejects,
     -    rollback_lock_file() discards the lockfile and the original index
     -    is left unchanged. When no hook is installed, the existing
     -    write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still
     -    taken.
     +    The hook gate checks cache_changed regardless of exit_status so that
     +    mixed-result adds (e.g., a tracked modification combined with an
     +    ignored path) still run the hook when index content changes.
      
          The hook is bypassed with "--no-verify" and is not invoked for
          --interactive, --patch, --edit, or --dry-run, nor by "git commit -a"
          which stages through its own code path.
      
     -    Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson
     -    and Makefile lists.
     -
          Signed-off-by: Chandra Kethi-Reddy <[email-masked]>
      
       ## Documentation/git-add.adoc ##
     @@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If yo
       filename of an ignored file, `git add` will fail with a list of ignored
       files. Otherwise it will silently ignore the file.
       
     -+A pre-add hook can be run to inspect or reject the proposed index update
     ++A `pre-add` hook can be run to inspect or reject the proposed index update
      +after `git add` computes staging and writes it to the index lockfile,
      +but before writing it to the final index. See linkgit:githooks[5].
      +
     @@ Documentation/git-add.adoc: for `git add --no-all <pathspec>...`, i.e. ignored r
       	information in the index.
       
      +`--no-verify`::
     -+	Bypass the pre-add hook if it exists. See linkgit:githooks[5] for
     ++	Bypass the `pre-add` hook if it exists. See linkgit:githooks[5] for
      +	more information about hooks.
      +
       `--ignore-errors`::
     @@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com
      +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,
      +`--edit`, or `--dry-run`.
      +
     -+It takes two parameters: the path to a copy of the index before this
     -+invocation of `git add`, and the path to the lockfile containing the
     -+proposed index after staging. It does not read from standard input.
     -+If no index exists yet, the first parameter names a path that does not
     -+exist and should be treated as an empty index. No special environment
     -+variables are set. The hook is invoked after the index has been updated
     -+in memory and written to the lockfile, but before it is committed to the
     -+final location.
     ++It takes two parameters: the path to the index file for this invocation
     ++of `git add`, and the path to the lockfile containing the proposed
     ++index after staging. It does not read from standard input. If no index
     ++exists yet, the first parameter names a path that does not exist and
     ++should be treated as an empty index.
     ++
     ++The hook is invoked after the index has been updated in memory and
     ++written to the lockfile, but before it is committed to the final index
     ++path. Exiting with a non-zero status causes `git add` to reject the
     ++proposed state, roll back the lockfile, and leave the index unchanged.
     ++Exiting with zero status allows the index update to be committed.
      +
     -+Exiting with a non-zero status causes `git add` to abort and leaves the
     -+index unchanged. Exiting with zero status causes the staged changes to
     -+take effect.
     ++Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may
     ++set `GIT_INDEX_FILE="$1"` to inspect current index state and
     ++`GIT_INDEX_FILE="$2"` to inspect proposed index state.
      +
      +This hook can be used to prevent staging of files based on names, content,
      +or sizes (e.g., to block `.env` files, secret keys, or large files).
      +
      +This hook is not invoked by `git commit -a` or `git commit --include`
     -+which still can run the pre-commit hook, providing a control point at
     ++which still can run the `pre-commit` hook, providing a control point at
      +commit time.
      +
       pre-commit
     @@ builtin/add.c
       #include "submodule.h"
       #include "add-interactive.h"
      +#include "hook.h"
     -+#include "copy.h"
     ++#include "abspath.h"
       
       static const char * const builtin_add_usage[] = {
       	N_("git add [<options>] [--] <pathspec>..."),
     @@ builtin/add.c: int cmd_add(int argc,
       	struct lock_file lock_file = LOCK_INIT;
       	struct odb_transaction *transaction;
      +	int run_pre_add = 0;
     -+	struct tempfile *orig_index = NULL;
      +	char *orig_index_path = NULL;
       
       	repo_config(repo, add_config, NULL);
     @@ builtin/add.c: int cmd_add(int argc,
       	}
       
      +	if (!show_only && !no_verify && find_hook(repo, "pre-add")) {
     -+		int fd_in, status;
     -+		const char *index_file = repo_get_index_file(repo);
     -+		char *template;
     -+
      +		run_pre_add = 1;
     -+		template = xstrfmt("%s.pre-add.XXXXXX", index_file);
     -+		orig_index = xmks_tempfile(template);
     -+		free(template);
     -+
     -+		fd_in = open(index_file, O_RDONLY);
     -+		if (fd_in >= 0) {
     -+			status = copy_fd(fd_in, get_tempfile_fd(orig_index));
     -+			if (close(fd_in))
     -+				die_errno(_("unable to close index for pre-add hook"));
     -+			if (close_tempfile_gently(orig_index))
     -+				die_errno(_("unable to close temporary index copy"));
     -+			if (status < 0)
     -+				die(_("failed to copy index for pre-add hook"));
     -+		} else if (errno == ENOENT) {
     -+			orig_index_path = xstrdup(get_tempfile_path(orig_index));
     -+			if (delete_tempfile(&orig_index))
     -+				die_errno(_("unable to remove temporary index copy"));
     -+		} else {
     -+			die_errno(_("unable to open index for pre-add hook"));
     -+		}
     ++		orig_index_path = absolute_pathdup(repo_get_index_file(repo));
      +	}
      +
       	transaction = odb_transaction_begin(repo->objects);
     @@ builtin/add.c: int cmd_add(int argc,
       	if (take_worktree_changes && !add_renormalize && !ignore_add_errors &&
      -	    report_path_error(ps_matched, &pathspec))
      +	    report_path_error(ps_matched, &pathspec)) {
     -+		if (orig_index)
     -+			delete_tempfile(&orig_index);
      +		free(orig_index_path);
       		exit(128);
      +	}
     @@ builtin/add.c: int cmd_add(int argc,
      -	if (write_locked_index(repo->index, &lock_file,
      -			       COMMIT_LOCK | SKIP_IF_UNCHANGED))
      -		die(_("unable to write new index file"));
     -+	if (run_pre_add && !exit_status && repo->index->cache_changed) {
     ++	if (run_pre_add && repo->index->cache_changed) {
      +		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
      +
     -+		if (write_locked_index(repo->index, &lock_file, 0))
     -+			die(_("unable to write new index file"));
     ++		if (write_locked_index(repo->index, &lock_file,
     ++				SKIP_INDEX_CHANGE_HOOK))
     ++			die(_("unable to write proposed index"));
      +
     -+		strvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :
     -+					     orig_index_path);
     ++		strvec_push(&opt.args, orig_index_path);
      +		strvec_push(&opt.args, get_lock_file_path(&lock_file));
      +		if (run_hooks_opt(repo, "pre-add", &opt)) {
      +			rollback_lock_file(&lock_file); /* hook rejected */
      +			exit_status = 1;
     ++		} else if (commit_lock_file(&lock_file)) {
     ++			die(_("unable to write new index file"));
      +		} else {
     -+			if (commit_lock_file(&lock_file)) /* hook approved */
     -+				die(_("unable to write new index file"));
     ++			run_hooks_l(repo, "post-index-change",
     ++				    repo->index->updated_workdir ? "1" : "0",
     ++				    repo->index->updated_skipworktree ? "1" : "0",
     ++				    NULL);
      +		}
     ++		repo->index->updated_workdir = 0;
     ++		repo->index->updated_skipworktree = 0;
      +	} else {
      +		if (write_locked_index(repo->index, &lock_file,
      +				       COMMIT_LOCK | SKIP_IF_UNCHANGED))
      +			die(_("unable to write new index file"));
      +	}
      +
     -+	delete_tempfile(&orig_index);
      +	free(orig_index_path);
       
       	free(ps_matched);
       	dir_clear(&dir);
      
     + ## read-cache-ll.h ##
     +@@ read-cache-ll.h: int is_index_unborn(struct index_state *);
     + /* For use with `write_locked_index()`. */
     + #define COMMIT_LOCK		(1 << 0)
     + #define SKIP_IF_UNCHANGED	(1 << 1)
     ++#define SKIP_INDEX_CHANGE_HOOK	(1 << 2)
     + 
     + /*
     +  * Write the index while holding an already-taken lock. Close the lock,
     +
     + ## read-cache.c ##
     +@@ read-cache.c: static int do_write_locked_index(struct index_state *istate,
     + 	else
     + 		ret = close_lock_file_gently(lock);
     + 
     +-	run_hooks_l(the_repository, "post-index-change",
     +-		    istate->updated_workdir ? "1" : "0",
     +-		    istate->updated_skipworktree ? "1" : "0", NULL);
     +-	istate->updated_workdir = 0;
     +-	istate->updated_skipworktree = 0;
     +-
     ++	if (!(flags & SKIP_INDEX_CHANGE_HOOK)) {
     ++		run_hooks_l(the_repository, "post-index-change",
     ++			    istate->updated_workdir ? "1" : "0",
     ++			    istate->updated_skipworktree ? "1" : "0", NULL);
     ++		istate->updated_workdir = 0;
     ++		istate->updated_skipworktree = 0;
     ++	}
     + 	return ret;
     + }
     + 
     +
       ## t/meson.build ##
      @@ t/meson.build: integration_tests = [
         't3703-add-magic-pathspec.sh',
     @@ t/t3706-pre-add-hook.sh (new)
      +	test_path_is_missing actual
      +'
      +
     -+test_expect_success 'hook receives original and proposed index as arguments' '
     -+	test_when_finished "rm -f tracked expected hook-ran" &&
     -+	echo "initial" >tracked &&
     -+	git add tracked &&
     -+	git commit -m "initial" &&
     ++test_expect_success 'setup for path-based tests' '
     ++	git add file &&
     ++	git commit -m "initial"
     ++'
     ++
     ++test_expect_success 'hook receives index-path and lockfile-path arguments' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f staged expect-count arg-count arg-one arg-two \
     ++			    expect-index expect-lockpath" &&
     ++	echo staged >staged &&
     ++	cat >expect-count <<-\EOF &&
     ++	2
     ++	EOF
      +	test_hook pre-add <<-\EOF &&
     -+	test $# -eq 2 &&
     -+	test -f "$1" &&
     -+	test -f "$2" &&
     -+	echo pass >hook-ran
     ++	echo "$#" >arg-count &&
     ++	echo "$1" >arg-one &&
     ++	echo "$2" >arg-two &&
     ++	test "$1" != "$2" &&
     ++	test -r "$2"
      +	EOF
     ++	git add staged &&
     ++	test_cmp expect-count arg-count &&
     ++	printf "%s/index\n" "$(git rev-parse --absolute-git-dir)" >expect-index &&
     ++	test_cmp expect-index arg-one &&
     ++	sed "s/$/.lock/" expect-index >expect-lockpath &&
     ++	test_cmp expect-lockpath arg-two
     ++'
      +
     -+	echo "modified" >tracked &&
     -+	git add tracked &&
     -+	echo pass >expected &&
     -+	test_cmp expected hook-ran
     ++test_expect_success 'hook rejection leaves final index unchanged' '
     ++	test_when_finished "git reset --hard && rm -f reject index.before" &&
     ++	cp .git/index index.before &&
     ++	test_hook pre-add <<-\EOF &&
     ++	exit 1
     ++	EOF
     ++	echo reject >reject &&
     ++	test_must_fail git add reject &&
     ++	test_cmp_bin index.before .git/index &&
     ++	test_path_is_missing .git/index.lock
      +'
      +
     -+test_expect_success 'hook handles first add with no existing index' '
     -+	test_when_finished "rm -rf no-index" &&
     -+	test_create_repo no-index &&
     -+	echo ok >no-index/expected &&
     -+	test_hook -C no-index pre-add <<-\EOF &&
     -+	test $# -eq 2 &&
     ++test_expect_success 'missing pre-existing index path treated as empty' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f newfile arg-one after.raw after expect-index" &&
     ++	rm -f .git/index &&
     ++	test_hook pre-add <<-\EOF &&
     ++	echo "$1" >arg-one &&
      +	test ! -e "$1" &&
     -+	test -f "$2" &&
     -+	echo ok >hook-ran
     ++	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >after.raw &&
     ++	sort after.raw >after
      +	EOF
     -+
     -+	echo first >no-index/file &&
     -+	git -C no-index add file &&
     -+	test_cmp no-index/expected no-index/hook-ran
     ++	echo newfile >newfile &&
     ++	git add newfile &&
     ++	printf "%s/index\n" "$(git rev-parse --absolute-git-dir)" >expect-index &&
     ++	test_cmp expect-index arg-one &&
     ++	grep "^newfile$" after &&
     ++	grep "^file$" after
      +'
      +
     -+test_expect_success 'hook is not invoked with --dry-run (show-only)' '
     -+	test_when_finished "rm -f actual" &&
     ++test_expect_success 'hook respects GIT_INDEX_FILE' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f arg-one arg-two expect-index expect-lockpath \
     ++			    alt-index alt-index.lock" &&
      +	test_hook pre-add <<-\EOF &&
     -+	echo should-not-run >>actual
     -+	exit 1
     ++	echo "$1" >arg-one &&
     ++	echo "$2" >arg-two
      +	EOF
     ++	echo changed >>file &&
     ++	GIT_INDEX_FILE=alt-index git add file &&
     ++	echo "$PWD/alt-index" >expect-index &&
     ++	test_cmp expect-index arg-one &&
     ++	echo "$PWD/alt-index.lock" >expect-lockpath &&
     ++	test_cmp expect-lockpath arg-two
     ++'
      +
     -+	echo content >file &&
     -+	git add --dry-run file &&
     -+	test_path_is_missing actual
     ++test_expect_success 'setup for mixed-result tests' '
     ++	echo "*.ignored" >.gitignore &&
     ++	git add .gitignore &&
     ++	git commit -m "add gitignore"
      +'
      +
     -+test_expect_success 'hook is invoked with git add -u' '
     -+	test_when_finished "rm -f actual expected file" &&
     -+	echo "initial" >file &&
     -+	git add file &&
     -+	git commit -m "initial" &&
     -+	echo "pre-add" >expected &&
     ++test_expect_success 'mixed-result add invokes pre-add hook' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f bad.ignored index.before hook-ran proposed" &&
     ++	echo changed >>file &&
     ++	echo ignored >bad.ignored &&
     ++	cp .git/index index.before &&
      +	test_hook pre-add <<-\EOF &&
     -+	echo pre-add >>actual
     ++	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >proposed &&
     ++	grep "^file$" proposed &&
     ++	echo invoked >hook-ran &&
     ++	exit 1
      +	EOF
     -+
     -+	echo modified >file &&
     -+	git add -u &&
     -+	test_cmp expected actual
     ++	test_must_fail git add file bad.ignored &&
     ++	test_path_is_file hook-ran &&
     ++	test_cmp_bin index.before .git/index &&
     ++	test_path_is_missing .git/index.lock
      +'
      +
     -+test_expect_success 'hook can compare original and proposed index' '
     -+	test_when_finished "rm -f old-raw new-raw old-list new-list \
     -+			    expected-old expected-new" &&
     -+	echo "initial" >file1 &&
     -+	echo "initial" >file2 &&
     -+	git add file1 file2 &&
     -+	git commit -m "initial" &&
     -+	echo "staged-before" >file1 &&
     -+	git add file1 &&
     ++test_expect_success 'mixed-result add stages tracked update on approve' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f bad.ignored hook-ran staged proposed" &&
     ++	echo changed >>file &&
     ++	echo ignored >bad.ignored &&
      +	test_hook pre-add <<-\EOF &&
     -+	GIT_INDEX_FILE="$1" git diff --cached --name-only HEAD >old-raw &&
     -+	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-raw &&
     -+	sort old-raw >old-list &&
     -+	sort new-raw >new-list
     ++	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >proposed &&
     ++	grep "^file$" proposed &&
     ++	echo invoked >hook-ran
      +	EOF
     -+
     -+	echo "modified" >file2 &&
     -+	git add file2 &&
     -+	echo file1 >expected-old &&
     -+	printf "%s\n" file1 file2 >expected-new &&
     -+	test_cmp expected-old old-list &&
     -+	test_cmp expected-new new-list
     ++	test_must_fail git add file bad.ignored &&
     ++	test_path_is_file hook-ran &&
     ++	git diff --cached --name-only HEAD >staged &&
     ++	grep "^file$" staged &&
     ++	test_path_is_missing .git/index.lock
      +'
      +
     -+test_expect_success 'hook rejection rolls back index unchanged' '
     -+	test_when_finished "rm -f file before after old-raw new-raw \
     -+			    old-list new-list expected-old expected-new" &&
     -+	echo "initial" >file &&
     ++test_expect_success 'post-index-change fires after pre-add approval' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f hook-order expect lockfile-present" &&
     ++	test_hook pre-add <<-\EOF &&
     ++	echo pre >>hook-order
     ++	EOF
     ++	test_hook post-index-change <<-\EOF &&
     ++	if test -f ".git/index.lock"
     ++	then
     ++		echo locked >lockfile-present
     ++	fi
     ++	echo post >>hook-order
     ++	EOF
     ++	echo updated >>file &&
      +	git add file &&
     -+	git commit -m "initial" &&
     -+	git diff --cached --name-only HEAD >before &&
     ++	cat >expect <<-\EOF &&
     ++	pre
     ++	post
     ++	EOF
     ++	test_cmp expect hook-order &&
     ++	test_path_is_missing lockfile-present
     ++'
     ++
     ++test_expect_success 'post-index-change is suppressed on pre-add rejection' '
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f index.before hook-order expect" &&
     ++	cp .git/index index.before &&
      +	test_hook pre-add <<-\EOF &&
     -+	GIT_INDEX_FILE="$1" git diff --cached --name-only HEAD >old-raw &&
     -+	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-raw &&
     -+	sort old-raw >old-list &&
     -+	sort new-raw >new-list &&
     ++	echo pre >>hook-order &&
      +	exit 1
      +	EOF
     -+
     -+	echo "modified" >file &&
     ++	test_hook post-index-change <<-\EOF &&
     ++	echo post >>hook-order
     ++	EOF
     ++	echo reject >>file &&
      +	test_must_fail git add file &&
     -+	git diff --cached --name-only HEAD >after &&
     -+	test_cmp before after &&
     -+	: >expected-old &&
     -+	echo file >expected-new &&
     -+	test_cmp expected-old old-list &&
     -+	test_cmp expected-new new-list
     ++	echo pre >expect &&
     ++	test_cmp expect hook-order &&
     ++	test_cmp_bin index.before .git/index &&
     ++	test_path_is_missing .git/index.lock
     ++'
     ++
     ++test_expect_success '--dry-run does not invoke hook' '
     ++	test_when_finished "rm -f hook-ran dry" &&
     ++	test_hook pre-add <<-\EOF &&
     ++	echo invoked >hook-ran
     ++	EOF
     ++	echo dry >dry &&
     ++	git add --dry-run dry &&
     ++	test_path_is_missing hook-ran
     ++'
     ++
     ++test_expect_success 'hook runs for git add -u' '
     ++	test_when_finished "git reset --hard && rm -f hook-ran" &&
     ++	test_hook pre-add <<-\EOF &&
     ++	echo invoked >hook-ran
     ++	EOF
     ++	echo changed >>file &&
     ++	git add -u &&
     ++	test_path_is_file hook-ran
      +'
      +
      +test_expect_success 'hook example: block .env files' '
     -+	test_when_finished "rm -f .env safe.txt new-paths" &&
     -+	echo "initial" >base &&
     -+	git add base &&
     -+	git commit -m "initial" &&
     ++	test_when_finished "git reset --hard &&
     ++			    rm -f .env safe.txt new-paths" &&
      +	test_hook pre-add <<-\EOF &&
      +	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-paths &&
      +	while read path
      +	do
      +		case "$path" in
     -+		*.env|.env)
     ++		*.env)
      +			echo "error: $path must not be staged" >&2
      +			exit 1
      +			;;
      +		esac
      +	done <new-paths
      +	EOF
     -+
      +	echo "DB_PASS=secret" >.env &&
      +	test_must_fail git add .env &&
      +	echo "safe content" >safe.txt &&
     @@ t/t3706-pre-add-hook.sh (new)
      +'
      +
      +test_expect_success 'hook example: block secrets in content' '
     -+	test_when_finished "rm -f config.txt secret" &&
     -+	echo "initial" >config.txt &&
     -+	git add config.txt &&
     -+	git commit -m "initial" &&
     ++	test_when_finished "git reset --hard && rm -f config.txt secret" &&
      +	test_hook pre-add <<-\EOF &&
      +	GIT_INDEX_FILE="$2" git diff --cached HEAD >secret &&
     -+	if grep -qE "(API_KEY|SECRET_KEY|PRIVATE_KEY)=" secret
     ++	if grep -q "API_KEY=" secret ||
     ++	   grep -q "SECRET_KEY=" secret ||
     ++	   grep -q "PRIVATE_KEY=" secret
      +	then
      +		echo "error: staged content contains secrets" >&2
      +		exit 1
      +	fi
      +	EOF
     -+
      +	echo "API_KEY=sksksk-live-12345" >config.txt &&
      +	test_must_fail git add config.txt &&
      +	echo "LOG_LEVEL=debug" >config.txt &&

 Documentation/git-add.adoc  |  11 +-
 Documentation/githooks.adoc |  30 ++++
 builtin/add.c               |  47 +++++-
 read-cache-ll.h             |   1 +
 read-cache.c                |  13 +-
 t/meson.build               |   1 +
 t/t3706-pre-add-hook.sh     | 289 ++++++++++++++++++++++++++++++++++++
 7 files changed, 381 insertions(+), 11 deletions(-)
 create mode 100755 t/t3706-pre-add-hook.sh

diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc
index 6192daeb03..b47751acca 100644
--- a/Documentation/git-add.adoc
+++ b/Documentation/git-add.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 [synopsis]
 git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]
 	[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]
-	[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]
+	[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]
 	[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
 	[--] [<pathspec>...]
 
@@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact
 filename of an ignored file, `git add` will fail with a list of ignored
 files. Otherwise it will silently ignore the file.
 
+A `pre-add` hook can be run to inspect or reject the proposed index update
+after `git add` computes staging and writes it to the index lockfile,
+but before writing it to the final index. See linkgit:githooks[5].
+
 Please see linkgit:git-commit[1] for alternative ways to add content to a
 commit.
 
@@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.
 	Don't add the file(s), but only refresh their stat()
 	information in the index.
 
+`--no-verify`::
+	Bypass the `pre-add` hook if it exists. See linkgit:githooks[5] for
+	more information about hooks.
+
 `--ignore-errors`::
 	If some files could not be added because of errors indexing
 	them, do not abort the operation, but continue adding the
@@ -451,6 +459,7 @@ linkgit:git-reset[1]
 linkgit:git-mv[1]
 linkgit:git-commit[1]
 linkgit:git-update-index[1]
+linkgit:githooks[5]
 
 GIT
 ---
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 056553788d..657e14d306 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.
 This hook is meant primarily for notification, and cannot affect
 the outcome of `git am`.
 
+pre-add
+~~~~~~~
+
+This hook is invoked by linkgit:git-add[1], and can be bypassed with the
+`--no-verify` option. It is not invoked for `--interactive`, `--patch`,
+`--edit`, or `--dry-run`.
+
+It takes two parameters: the path to the index file for this invocation
+of `git add`, and the path to the lockfile containing the proposed
+index after staging. It does not read from standard input. If no index
+exists yet, the first parameter names a path that does not exist and
+should be treated as an empty index.
+
+The hook is invoked after the index has been updated in memory and
+written to the lockfile, but before it is committed to the final index
+path. Exiting with a non-zero status causes `git add` to reject the
+proposed state, roll back the lockfile, and leave the index unchanged.
+Exiting with zero status allows the index update to be committed.
+
+Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may
+set `GIT_INDEX_FILE="$1"` to inspect current index state and
+`GIT_INDEX_FILE="$2"` to inspect proposed index state.
+
+This hook can be used to prevent staging of files based on names, content,
+or sizes (e.g., to block `.env` files, secret keys, or large files).
+
+This hook is not invoked by `git commit -a` or `git commit --include`
+which still can run the `pre-commit` hook, providing a control point at
+commit time.
+
 pre-commit
 ~~~~~~~~~~
 
diff --git a/builtin/add.c b/builtin/add.c
index 32709794b3..d4d004a35b 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -25,6 +25,8 @@
 #include "strvec.h"
 #include "submodule.h"
 #include "add-interactive.h"
+#include "hook.h"
+#include "abspath.h"
 
 static const char * const builtin_add_usage[] = {
 	N_("git add [<options>] [--] <pathspec>..."),
@@ -36,6 +38,7 @@ static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
 static int include_sparse;
+static int no_verify;
 static const char *pathspec_from_file;
 
 static int chmod_pathspec(struct repository *repo,
@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {
 	OPT_BOOL( 0 , "refresh", &refresh_only, N_("don't add, only refresh the index")),
 	OPT_BOOL( 0 , "ignore-errors", &ignore_add_errors, N_("just skip files which cannot be added because of errors")),
 	OPT_BOOL( 0 , "ignore-missing", &ignore_missing, N_("check if - even missing - files are ignored in dry run")),
+	OPT_BOOL( 0 , "no-verify", &no_verify, N_("bypass pre-add hook")),
 	OPT_BOOL(0, "sparse", &include_sparse, N_("allow updating entries outside of the sparse-checkout cone")),
 	OPT_STRING(0, "chmod", &chmod_arg, "(+|-)x",
 		   N_("override the executable bit of the listed files")),
@@ -391,6 +395,8 @@ int cmd_add(int argc,
 	char *ps_matched = NULL;
 	struct lock_file lock_file = LOCK_INIT;
 	struct odb_transaction *transaction;
+	int run_pre_add = 0;
+	char *orig_index_path = NULL;
 
 	repo_config(repo, add_config, NULL);
 
@@ -576,6 +582,11 @@ int cmd_add(int argc,
 		string_list_clear(&only_match_skip_worktree, 0);
 	}
 
+	if (!show_only && !no_verify && find_hook(repo, "pre-add")) {
+		run_pre_add = 1;
+		orig_index_path = absolute_pathdup(repo_get_index_file(repo));
+	}
+
 	transaction = odb_transaction_begin(repo->objects);
 
 	ps_matched = xcalloc(pathspec.nr, 1);
@@ -587,8 +598,10 @@ int cmd_add(int argc,
 						  include_sparse, flags);
 
 	if (take_worktree_changes && !add_renormalize && !ignore_add_errors &&
-	    report_path_error(ps_matched, &pathspec))
+	    report_path_error(ps_matched, &pathspec)) {
+		free(orig_index_path);
 		exit(128);
+	}
 
 	if (add_new_files)
 		exit_status |= add_files(repo, &dir, flags);
@@ -598,9 +611,35 @@ int cmd_add(int argc,
 	odb_transaction_commit(transaction);
 
 finish:
-	if (write_locked_index(repo->index, &lock_file,
-			       COMMIT_LOCK | SKIP_IF_UNCHANGED))
-		die(_("unable to write new index file"));
+	if (run_pre_add && repo->index->cache_changed) {
+		struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
+
+		if (write_locked_index(repo->index, &lock_file,
+				SKIP_INDEX_CHANGE_HOOK))
+			die(_("unable to write proposed index"));
+
+		strvec_push(&opt.args, orig_index_path);
+		strvec_push(&opt.args, get_lock_file_path(&lock_file));
+		if (run_hooks_opt(repo, "pre-add", &opt)) {
+			rollback_lock_file(&lock_file); /* hook rejected */
+			exit_status = 1;
+		} else if (commit_lock_file(&lock_file)) {
+			die(_("unable to write new index file"));
+		} else {
+			run_hooks_l(repo, "post-index-change",
+				    repo->index->updated_workdir ? "1" : "0",
+				    repo->index->updated_skipworktree ? "1" : "0",
+				    NULL);
+		}
+		repo->index->updated_workdir = 0;
+		repo->index->updated_skipworktree = 0;
+	} else {
+		if (write_locked_index(repo->index, &lock_file,
+				       COMMIT_LOCK | SKIP_IF_UNCHANGED))
+			die(_("unable to write new index file"));
+	}
+
+	free(orig_index_path);
 
 	free(ps_matched);
 	dir_clear(&dir);
diff --git a/read-cache-ll.h b/read-cache-ll.h
index 71b49d9af4..a43971c07e 100644
--- a/read-cache-ll.h
+++ b/read-cache-ll.h
@@ -284,6 +284,7 @@ int is_index_unborn(struct index_state *);
 /* For use with `write_locked_index()`. */
 #define COMMIT_LOCK		(1 << 0)
 #define SKIP_IF_UNCHANGED	(1 << 1)
+#define SKIP_INDEX_CHANGE_HOOK	(1 << 2)
 
 /*
  * Write the index while holding an already-taken lock. Close the lock,
diff --git a/read-cache.c b/read-cache.c
index 0c07c3aef7..5051cda4ce 100644
--- a/read-cache.c
+++ b/read-cache.c
@@ -3161,12 +3161,13 @@ static int do_write_locked_index(struct index_state *istate,
 	else
 		ret = close_lock_file_gently(lock);
 
-	run_hooks_l(the_repository, "post-index-change",
-		    istate->updated_workdir ? "1" : "0",
-		    istate->updated_skipworktree ? "1" : "0", NULL);
-	istate->updated_workdir = 0;
-	istate->updated_skipworktree = 0;
-
+	if (!(flags & SKIP_INDEX_CHANGE_HOOK)) {
+		run_hooks_l(the_repository, "post-index-change",
+			    istate->updated_workdir ? "1" : "0",
+			    istate->updated_skipworktree ? "1" : "0", NULL);
+		istate->updated_workdir = 0;
+		istate->updated_skipworktree = 0;
+	}
 	return ret;
 }
 
diff --git a/t/meson.build b/t/meson.build
index f80e366cff..2419a9adbb 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -415,6 +415,7 @@ integration_tests = [
   't3703-add-magic-pathspec.sh',
   't3704-add-pathspec-file.sh',
   't3705-add-sparse-checkout.sh',
+  't3706-pre-add-hook.sh',
   't3800-mktag.sh',
   't3900-i18n-commit.sh',
   't3901-i18n-patch.sh',
diff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh
new file mode 100755
index 0000000000..f5092f0727
--- /dev/null
+++ b/t/t3706-pre-add-hook.sh
@@ -0,0 +1,289 @@
+#!/bin/sh
+
+test_description='pre-add hook tests
+
+These tests run git add with and without pre-add hooks to ensure functionality. Largely derived from t7503 (pre-commit and pre-merge-commit hooks) and t5571 (pre-push hooks).'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'with no hook' '
+	test_when_finished "rm -f actual" &&
+	echo content >file &&
+	git add file &&
+	test_path_is_missing actual
+'
+
+test_expect_success POSIXPERM 'with non-executable hook' '
+	test_when_finished "rm -f actual" &&
+	test_hook pre-add <<-\EOF &&
+	echo should-not-run >>actual
+	exit 1
+	EOF
+	chmod -x .git/hooks/pre-add &&
+
+	echo content >file &&
+	git add file &&
+	test_path_is_missing actual
+'
+
+test_expect_success '--no-verify with no hook' '
+	echo content >file &&
+	git add --no-verify file &&
+	test_path_is_missing actual
+'
+
+test_expect_success 'with succeeding hook' '
+	test_when_finished "rm -f actual expected" &&
+	echo "pre-add" >expected &&
+	test_hook pre-add <<-\EOF &&
+	echo pre-add >>actual
+	EOF
+
+	echo content >file &&
+	git add file &&
+	test_cmp expected actual
+'
+
+test_expect_success 'with failing hook' '
+	test_when_finished "rm -f actual" &&
+	test_hook pre-add <<-\EOF &&
+	echo pre-add-rejected >>actual
+	exit 1
+	EOF
+
+	echo content >file &&
+	test_must_fail git add file
+'
+
+test_expect_success '--no-verify with failing hook' '
+	test_when_finished "rm -f actual" &&
+	test_hook pre-add <<-\EOF &&
+	echo should-not-run >>actual
+	exit 1
+	EOF
+
+	echo content >file &&
+	git add --no-verify file &&
+	test_path_is_missing actual
+'
+
+test_expect_success 'setup for path-based tests' '
+	git add file &&
+	git commit -m "initial"
+'
+
+test_expect_success 'hook receives index-path and lockfile-path arguments' '
+	test_when_finished "git reset --hard &&
+			    rm -f staged expect-count arg-count arg-one arg-two \
+			    expect-index expect-lockpath" &&
+	echo staged >staged &&
+	cat >expect-count <<-\EOF &&
+	2
+	EOF
+	test_hook pre-add <<-\EOF &&
+	echo "$#" >arg-count &&
+	echo "$1" >arg-one &&
+	echo "$2" >arg-two &&
+	test "$1" != "$2" &&
+	test -r "$2"
+	EOF
+	git add staged &&
+	test_cmp expect-count arg-count &&
+	printf "%s/index\n" "$(git rev-parse --absolute-git-dir)" >expect-index &&
+	test_cmp expect-index arg-one &&
+	sed "s/$/.lock/" expect-index >expect-lockpath &&
+	test_cmp expect-lockpath arg-two
+'
+
+test_expect_success 'hook rejection leaves final index unchanged' '
+	test_when_finished "git reset --hard && rm -f reject index.before" &&
+	cp .git/index index.before &&
+	test_hook pre-add <<-\EOF &&
+	exit 1
+	EOF
+	echo reject >reject &&
+	test_must_fail git add reject &&
+	test_cmp_bin index.before .git/index &&
+	test_path_is_missing .git/index.lock
+'
+
+test_expect_success 'missing pre-existing index path treated as empty' '
+	test_when_finished "git reset --hard &&
+			    rm -f newfile arg-one after.raw after expect-index" &&
+	rm -f .git/index &&
+	test_hook pre-add <<-\EOF &&
+	echo "$1" >arg-one &&
+	test ! -e "$1" &&
+	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >after.raw &&
+	sort after.raw >after
+	EOF
+	echo newfile >newfile &&
+	git add newfile &&
+	printf "%s/index\n" "$(git rev-parse --absolute-git-dir)" >expect-index &&
+	test_cmp expect-index arg-one &&
+	grep "^newfile$" after &&
+	grep "^file$" after
+'
+
+test_expect_success 'hook respects GIT_INDEX_FILE' '
+	test_when_finished "git reset --hard &&
+			    rm -f arg-one arg-two expect-index expect-lockpath \
+			    alt-index alt-index.lock" &&
+	test_hook pre-add <<-\EOF &&
+	echo "$1" >arg-one &&
+	echo "$2" >arg-two
+	EOF
+	echo changed >>file &&
+	GIT_INDEX_FILE=alt-index git add file &&
+	echo "$PWD/alt-index" >expect-index &&
+	test_cmp expect-index arg-one &&
+	echo "$PWD/alt-index.lock" >expect-lockpath &&
+	test_cmp expect-lockpath arg-two
+'
+
+test_expect_success 'setup for mixed-result tests' '
+	echo "*.ignored" >.gitignore &&
+	git add .gitignore &&
+	git commit -m "add gitignore"
+'
+
+test_expect_success 'mixed-result add invokes pre-add hook' '
+	test_when_finished "git reset --hard &&
+			    rm -f bad.ignored index.before hook-ran proposed" &&
+	echo changed >>file &&
+	echo ignored >bad.ignored &&
+	cp .git/index index.before &&
+	test_hook pre-add <<-\EOF &&
+	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >proposed &&
+	grep "^file$" proposed &&
+	echo invoked >hook-ran &&
+	exit 1
+	EOF
+	test_must_fail git add file bad.ignored &&
+	test_path_is_file hook-ran &&
+	test_cmp_bin index.before .git/index &&
+	test_path_is_missing .git/index.lock
+'
+
+test_expect_success 'mixed-result add stages tracked update on approve' '
+	test_when_finished "git reset --hard &&
+			    rm -f bad.ignored hook-ran staged proposed" &&
+	echo changed >>file &&
+	echo ignored >bad.ignored &&
+	test_hook pre-add <<-\EOF &&
+	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >proposed &&
+	grep "^file$" proposed &&
+	echo invoked >hook-ran
+	EOF
+	test_must_fail git add file bad.ignored &&
+	test_path_is_file hook-ran &&
+	git diff --cached --name-only HEAD >staged &&
+	grep "^file$" staged &&
+	test_path_is_missing .git/index.lock
+'
+
+test_expect_success 'post-index-change fires after pre-add approval' '
+	test_when_finished "git reset --hard &&
+			    rm -f hook-order expect lockfile-present" &&
+	test_hook pre-add <<-\EOF &&
+	echo pre >>hook-order
+	EOF
+	test_hook post-index-change <<-\EOF &&
+	if test -f ".git/index.lock"
+	then
+		echo locked >lockfile-present
+	fi
+	echo post >>hook-order
+	EOF
+	echo updated >>file &&
+	git add file &&
+	cat >expect <<-\EOF &&
+	pre
+	post
+	EOF
+	test_cmp expect hook-order &&
+	test_path_is_missing lockfile-present
+'
+
+test_expect_success 'post-index-change is suppressed on pre-add rejection' '
+	test_when_finished "git reset --hard &&
+			    rm -f index.before hook-order expect" &&
+	cp .git/index index.before &&
+	test_hook pre-add <<-\EOF &&
+	echo pre >>hook-order &&
+	exit 1
+	EOF
+	test_hook post-index-change <<-\EOF &&
+	echo post >>hook-order
+	EOF
+	echo reject >>file &&
+	test_must_fail git add file &&
+	echo pre >expect &&
+	test_cmp expect hook-order &&
+	test_cmp_bin index.before .git/index &&
+	test_path_is_missing .git/index.lock
+'
+
+test_expect_success '--dry-run does not invoke hook' '
+	test_when_finished "rm -f hook-ran dry" &&
+	test_hook pre-add <<-\EOF &&
+	echo invoked >hook-ran
+	EOF
+	echo dry >dry &&
+	git add --dry-run dry &&
+	test_path_is_missing hook-ran
+'
+
+test_expect_success 'hook runs for git add -u' '
+	test_when_finished "git reset --hard && rm -f hook-ran" &&
+	test_hook pre-add <<-\EOF &&
+	echo invoked >hook-ran
+	EOF
+	echo changed >>file &&
+	git add -u &&
+	test_path_is_file hook-ran
+'
+
+test_expect_success 'hook example: block .env files' '
+	test_when_finished "git reset --hard &&
+			    rm -f .env safe.txt new-paths" &&
+	test_hook pre-add <<-\EOF &&
+	GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-paths &&
+	while read path
+	do
+		case "$path" in
+		*.env)
+			echo "error: $path must not be staged" >&2
+			exit 1
+			;;
+		esac
+	done <new-paths
+	EOF
+	echo "DB_PASS=secret" >.env &&
+	test_must_fail git add .env &&
+	echo "safe content" >safe.txt &&
+	git add safe.txt
+'
+
+test_expect_success 'hook example: block secrets in content' '
+	test_when_finished "git reset --hard && rm -f config.txt secret" &&
+	test_hook pre-add <<-\EOF &&
+	GIT_INDEX_FILE="$2" git diff --cached HEAD >secret &&
+	if grep -q "API_KEY=" secret ||
+	   grep -q "SECRET_KEY=" secret ||
+	   grep -q "PRIVATE_KEY=" secret
+	then
+		echo "error: staged content contains secrets" >&2
+		exit 1
+	fi
+	EOF
+	echo "API_KEY=sksksk-live-12345" >config.txt &&
+	test_must_fail git add config.txt &&
+	echo "LOG_LEVEL=debug" >config.txt &&
+	git add config.txt
+'
+
+test_done

base-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4
-- 
gitgitgadget