{"thread":{"id":"64972","subject":"[PATCH] add: support pre-add hook","startedAt":"2026-02-10T15:32:56Z","lastAt":"2026-03-13T14:39:49Z","messageCount":26,"participants":["Chandra Kethi-Reddy via GitGitGadget","Junio C Hamano","Chandra","Ben Knoble","Phillip Wood","Adrian Ratiu"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"535682","messageId":"pull.2045.git.1770737573475.gitgitgadget@gmail.com","threadId":"64972","inReplyTo":null,"subject":"[PATCH] add: support pre-add hook","fromName":"Chandra Kethi-Reddy via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-10T15:32:53Z","receivedAt":"2026-02-10T15:32:56Z","isPatch":true,"sender":{"key":"name:Chandra Kethi-Reddy","avatar":null},"body":"From: Chandra Kethi-Reddy <chandrakr@pm.me>\n\ngit has no hook that fires during 'git add'.  Users who want to\nvalidate files before staging must wrap 'git add' in a shell alias\nor wait for pre-commit, which fires after staging is already done.\n\nAdd a pre-add hook that runs after pathspec validation and before\nany files are staged.  If the hook exits non-zero, 'git add' aborts\nwithout modifying the index.  The hook receives GIT_INDEX_FILE in\nits environment, following the same convention as pre-commit.\n\nThe hook is bypassed with '--no-verify' (long flag only, since '-n'\nis already '--dry-run' in 'git add').  It is not invoked for\n--interactive, --patch, --edit, or --dry-run modes, nor by\n'git commit -a' which stages files through its own code path in\nbuiltin/commit.c.\n\nThe implementation calls run_hooks_opt() directly rather than the\nrun_commit_hook() wrapper, which sets GIT_EDITOR=: and is not\nrelevant for 'git add'.  When no hook is installed, there is no\nperformance impact.\n\nDisclosure: developed with guidance from Claude Code (Anthropic)\nand Codex CLI (OpenAI) for development, review and standards\ncompliance. The contributor handtyped and reviewed all tests, code,\nand documentation.\n\nSigned-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n---\n    add: support pre-add hook\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2045\n\n Documentation/git-add.adoc  |  10 ++-\n Documentation/githooks.adoc |  17 ++++++\n builtin/add.c               |  14 +++++\n t/t3706-pre-add-hook.sh     | 117 ++++++++++++++++++++++++++++++++++++\n 4 files changed, 157 insertions(+), 1 deletion(-)\n create mode 100644 t/t3706-pre-add-hook.sh\n\ndiff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\nindex 6192daeb03..c60e0c65a5 100644\n--- a/Documentation/git-add.adoc\n+++ b/Documentation/git-add.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n [synopsis]\n git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n-\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n+\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n \t[--] [<pathspec>...]\n \n@@ -42,6 +42,9 @@ use the `--force` option to add ignored files. If you specify the exact\n filename of an ignored file, `git add` will fail with a list of ignored\n files. Otherwise it will silently ignore the file.\n \n+A pre-add hook can be run to inspect or reject the add operation before\n+it stages files. See linkgit:githooks[5] for details.\n+\n Please see linkgit:git-commit[1] for alternative ways to add content to a\n commit.\n \n@@ -163,6 +166,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n \tDon't add the file(s), but only refresh their stat()\n \tinformation in the index.\n \n+`--no-verify`::\n+\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n+\tmore information about hooks.\n+\n `--ignore-errors`::\n \tIf some files could not be added because of errors indexing\n \tthem, do not abort the operation, but continue adding the\n@@ -451,6 +458,7 @@ linkgit:git-reset[1]\n linkgit:git-mv[1]\n linkgit:git-commit[1]\n linkgit:git-update-index[1]\n+linkgit:githooks[5]\n \n GIT\n ---\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 056553788d..51156822bc 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -94,6 +94,23 @@ and is invoked after the patch is applied and a commit is made.\n This hook is meant primarily for notification, and cannot affect\n the outcome of `git am`.\n \n+pre-add\n+~~~~~~~\n+\n+This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n+`--no-verify` option. This hook is not invoked for `--interactive`, `--patch`,\n+`--edit`, or `--dry-run`. It takes no parameters, and is invoked after pathspec\n+validation and before any files are staged. Exiting with a non-zero status\n+from this script causes the `git add` command to abort without modifying the\n+index.\n+\n+This hook is invoked with the environment variable `GIT_INDEX_FILE`\n+which points to the index file. This allows the hook to inspect what\n+files would be staged before the operation proceeds.\n+\n+This hook is not invoked by `git commit -a` or `git commit --include` which\n+still can run the pre-commit hook, providing a control point at commit time.\n+\n pre-commit\n ~~~~~~~~~~\n \ndiff --git a/builtin/add.c b/builtin/add.c\nindex 32709794b3..7747b41d10 100644\n--- a/builtin/add.c\n+++ b/builtin/add.c\n@@ -25,6 +25,7 @@\n #include \"strvec.h\"\n #include \"submodule.h\"\n #include \"add-interactive.h\"\n+#include \"hook.h\"\n \n static const char * const builtin_add_usage[] = {\n \tN_(\"git add [<options>] [--] <pathspec>...\"),\n@@ -36,6 +37,7 @@ static int take_worktree_changes;\n static int add_renormalize;\n static int pathspec_file_nul;\n static int include_sparse;\n+static int no_verify;\n static const char *pathspec_from_file;\n \n static int chmod_pathspec(struct repository *repo,\n@@ -271,6 +273,7 @@ static struct option builtin_add_options[] = {\n \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n+\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n \t\t   N_(\"override the executable bit of the listed files\")),\n@@ -576,6 +579,17 @@ int cmd_add(int argc,\n \t\tstring_list_clear(&only_match_skip_worktree, 0);\n \t}\n \n+\tif (!show_only && !no_verify) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\n+\t\tstrvec_pushf(&opt.env, \"GIT_INDEX_FILE=%s\",\n+\t\t\t     repo_get_index_file(repo));\n+\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n+\t\t\texit_status = 1;\n+\t\t\tgoto finish;\n+\t\t}\n+\t}\n+\n \ttransaction = odb_transaction_begin(repo->objects);\n \n \tps_matched = xcalloc(pathspec.nr, 1);\ndiff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\nnew file mode 100644\nindex 0000000000..e64ee51b25\n--- /dev/null\n+++ b/t/t3706-pre-add-hook.sh\n@@ -0,0 +1,117 @@\n+#!/bin/sh\n+\n+test_description='pre-add hook tests\n+\n+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).'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'with no hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success POSIXPERM 'with non-executable hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\tchmod -x .git/hooks/pre-add &&\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success '--no-verify with no hook' '\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'with succeeding hook' '\n+\ttest_when_finished \"rm -f actual expected\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add-rejected >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\ttest_must_fail git add file\n+'\n+\n+test_expect_success '--no-verify with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'hook receives GIT_INDEX_FILE environment variable' '\n+\ttest_when_finished \"rm -f actual expected\" &&\n+\techo \"hook-saw-env\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tif test -z \"$GIT_INDEX_FILE\"\n+\tthen\n+\t\techo hook-missing-env >>actual\n+\telse\n+\t\techo hook-saw-env >>actual\n+\tfi\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'with --dry-run (show-only) the hook is not invoked' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --dry-run file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'hook is invoked with git add -u' '\n+\ttest_when_finished \"rm -f actual expected file\" &&\n+\techo \"initial\" >file &&\n+\tgit add file &&\n+\tgit commit -m \"initial\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo modified >file &&\n+\tgit add -u &&\n+\ttest_cmp expected actual\n+'\n+\n+test_done\n\nbase-commit: b2826b52eb7caff9f4ed6e85ec45e338bf02ad09\n-- \ngitgitgadget\n"},{"id":"535695","messageId":"xmqqldh0zcpa.fsf@gitster.g","threadId":"64972","inReplyTo":"pull.2045.git.1770737573475.gitgitgadget@gmail.com","subject":"Re: [PATCH] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-10T18:16:17Z","receivedAt":"2026-02-10T18:16:20Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> @@ -576,6 +579,17 @@ int cmd_add(int argc,\n>  \t\tstring_list_clear(&only_match_skip_worktree, 0);\n>  \t}\n>  \n> +\tif (!show_only && !no_verify) {\n> +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> +\n> +\t\tstrvec_pushf(&opt.env, \"GIT_INDEX_FILE=%s\",\n> +\t\t\t     repo_get_index_file(repo));\n> +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> +\t\t\texit_status = 1;\n> +\t\t\tgoto finish;\n> +\t\t}\n> +\t}\n> +\n>  \ttransaction = odb_transaction_begin(repo->objects);\n>  \n>  \tps_matched = xcalloc(pathspec.nr, 1);\n\nHmph, unless I am confused, I am a bit disappointed.  The code\nsnippet whose beginning we can see in the post context is\npreparation for determining which paths are going to be updated, and\nthis new code happens before anything is added to the in-core index.\n\nThe hook takes no clue from anything derived from the command line,\nnot even the pathspec (or list of individual paths computed using\nthe pathspec by the command) or the mode of operation like '-u' or\n'--renormalize'.  I am not sure how effective a decision the invoked\nhook can make to approve or deny in this lack of information.\n\nAlso I am not sure what good it is doing to pass GIT_INDEX_FILE as\nan environment variable.  If this were a hook that is invoked by\n\"git commit\", which may be doing a partial commit \"git commit [-o]\npath\", the command involves multiple on-disk index files to allow\nthe changes to named paths jump over already added changes to other\npaths, but \"git add path\" is always inclusive of already added\nchanges, and does not use anything but the main index file being\nused.\n\nSo,...\n"},{"id":"535704","messageId":"xmqq8qd0zan1.fsf@gitster.g","threadId":"64972","inReplyTo":"xmqqldh0zcpa.fsf@gitster.g","subject":"Re: [PATCH] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-10T19:00:50Z","receivedAt":"2026-02-10T19:00:54Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> The hook takes no clue from anything derived from the command line,\n> not even the pathspec (or list of individual paths computed using\n> the pathspec by the command) or the mode of operation like '-u' or\n> '--renormalize'.  I am not sure how effective a decision the invoked\n> hook can make to approve or deny in this lack of information.\n\nAnd I do not necessarily suggest passing the pathspec arguments or\ncommand line options that the \"git add\" command received from its\ncaller down to the hook, which will force hook authors to emulate\nwhat \"git add\" would do to these arguments and options, and they\nwill certainly get it wrong.\n\nI wonder if we can split write_locked_index() into two so that\nwriting out the in-core index to the temporary/lockfile can happen\nseparately from the call to commit_locked_index().  If we can do so,\nthen the following would become a viable and better implementation\nof this new feature to run the \"pre-add\" hook:\n\n * Determine if we will need to run this \"pre-add\" hook, at the\n   location in the code you addded the run_hooks_opt() invocation,\n   but do *NOT* run any hook there yet.\n\n * Instead, create a temporary copy of the index file if the above\n   says \"Yes, we are going to run the hook\".\n\n * Let the code path to update the in-core index, i.e., letting\n   everythning up to the \"finish:\" label to run normally.\n\n * Perform the first-half of the write_locked_index(), writing the\n   new index contents into the lockfile, but stopping before\n   committing it to the final name.\n\n * If we are running the hook, run it with two arguments, the name\n   of the temporary copy of the original index we created earlier,\n   and the name of this lockfile that has the proposed contents of\n   the index if the hook allowed \"git add\" to proceed.\n\n * If we ran the hook and hook succeeded, or if we did not have to\n   run the hook at all, then commit the lockfile.  Otherwise abort\n   the \"git add\" command and rollback_lock_file().\n\n * Remove the temporary file we created earlier (if any).\n\nYour hooks can \"GIT_INDEX_FILE=$1 git diff --cached --name-only\" to\nfind out which paths already had changes added before this\ninvocation of \"git add\", and similarly using $2 get the list of\npaths that will add further changes with this invocation.  The\nlatter set of paths you can inspect to see if you like the\nadditional changes brought in, perhaps like\n\n    #!/bin/sh\n    paths=$(GIT_INDEX_FILE=$2 git diff --cached --name-only)\n    GIT_INDEX_FILE=$1 git diff $paths >patch.txt\n\n    if grep \"^+.*secret\" patch.txt\n    then\n        echo \"do not divulge company secret!\" >&2\n\texit 1\n    fi\n\nor something.\n"},{"id":"535773","messageId":"pull.2045.v2.git.1770822312474.gitgitgadget@gmail.com","threadId":"64972","inReplyTo":"pull.2045.git.1770737573475.gitgitgadget@gmail.com","subject":"[PATCH v2] add: support pre-add hook","fromName":"Chandra Kethi-Reddy via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-11T15:05:12Z","receivedAt":"2026-02-11T15:05:25Z","isPatch":true,"sender":{"key":"name:Chandra Kethi-Reddy","avatar":null},"body":"From: Chandra Kethi-Reddy <chandrakr@pm.me>\n\n\"git add\" has no hook that lets users inspect what is about to be\nstaged. Users who want to reject certain paths or content must\nwrap the command in a shell alias or wait for pre-commit, which\nfires after staging is already done and objects may already be in\nthe object database.\n\nIntroduce a \"pre-add\" hook that runs after \"git add\" computes the\nnew index state but before committing it to disk. The hook\nreceives two arguments:\n\n  $1 -- path to a temporary copy of the index before this \"git add\"\n  $2 -- path to the lockfile containing the proposed index\n\n$1 on first add can be a non-existent path representing an empty\nindex.\n\nHook authors can inspect the computed result with ordinary tools:\n\n  GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n\nwithout needing to interpret pathspec or mode flags like \"-u\" or\n\"--renormalize\" -- the proposed index already reflects their effect.\n\nThe implementation creates a temporary copy of the index via the\ntempfile API when find_hook(\"pre-add\") reports a hook is present,\nthen lets all staging proceed normally. At the finish label,\nwrite_locked_index() writes the proposed index to the lockfile\nwithout COMMIT_LOCK. If the hook approves, commit_lock_file()\natomically replaces the index. If the hook rejects,\nrollback_lock_file() discards the lockfile and the original index\nis left unchanged. When no hook is installed, the existing\nwrite_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still\ntaken.\n\nThe hook is bypassed with \"--no-verify\" and is not invoked for\n--interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\nwhich stages through its own code path.\n\nRegister t3706-pre-add-hook.sh in t/meson.build to synchronize Meson\nand Makefile lists.\n\nSigned-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n---\n    add: support pre-add hook\n    \n    \n    Summary\n    =======\n    \n     * v2 reworks pre-add to follow Junio's suggested architecture: snapshot\n       original index, compute staging normally, write proposed index to\n       lockfile, run hook with ($1 original, $2 proposed), then\n       commit_lock_file() or rollback_lock_file().\n     * Hook authors now inspect computed results directly with\n       GIT_INDEX_FILE=\"$1\" / GIT_INDEX_FILE=\"$2\" instead of trying to\n       emulate pathspec/mode behavior.\n     * Added tests for two-argument contract, original-vs-proposed\n       comparison, explicit rollback behavior on hook rejection, and example\n       policies (filename/content rejection).\n    \n    \n    Notes\n    =====\n    \n     * This design intentionally trades ODB prevention for correctness of\n       hook inputs: blobs may already be written to object storage when the\n       hook runs, but hook rejection still leaves the on-disk index\n       unchanged.\n     * Conflicts with ar/parallel-hooks on seen:\n       RUN_HOOKS_OPT_INIT → RUN_HOOKS_OPT_INIT_SERIAL.\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v2\nPull-Request: https://github.com/gitgitgadget/git/pull/2045\n\nRange-diff vs v1:\n\n 1:  964bec5b9ea ! 1:  10244150e24 add: support pre-add hook\n     @@ Metadata\n       ## Commit message ##\n          add: support pre-add hook\n      \n     -    git has no hook that fires during 'git add'.  Users who want to\n     -    validate files before staging must wrap 'git add' in a shell alias\n     -    or wait for pre-commit, which fires after staging is already done.\n     +    \"git add\" has no hook that lets users inspect what is about to be\n     +    staged. Users who want to reject certain paths or content must\n     +    wrap the command in a shell alias or wait for pre-commit, which\n     +    fires after staging is already done and objects may already be in\n     +    the object database.\n      \n     -    Add a pre-add hook that runs after pathspec validation and before\n     -    any files are staged.  If the hook exits non-zero, 'git add' aborts\n     -    without modifying the index.  The hook receives GIT_INDEX_FILE in\n     -    its environment, following the same convention as pre-commit.\n     +    Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n     +    new index state but before committing it to disk. The hook\n     +    receives two arguments:\n      \n     -    The hook is bypassed with '--no-verify' (long flag only, since '-n'\n     -    is already '--dry-run' in 'git add').  It is not invoked for\n     -    --interactive, --patch, --edit, or --dry-run modes, nor by\n     -    'git commit -a' which stages files through its own code path in\n     -    builtin/commit.c.\n     +      $1 -- path to a temporary copy of the index before this \"git add\"\n     +      $2 -- path to the lockfile containing the proposed index\n      \n     -    The implementation calls run_hooks_opt() directly rather than the\n     -    run_commit_hook() wrapper, which sets GIT_EDITOR=: and is not\n     -    relevant for 'git add'.  When no hook is installed, there is no\n     -    performance impact.\n     +    $1 on first add can be a non-existent path representing an empty\n     +    index.\n      \n     -    Disclosure: developed with guidance from Claude Code (Anthropic)\n     -    and Codex CLI (OpenAI) for development, review and standards\n     -    compliance. The contributor handtyped and reviewed all tests, code,\n     -    and documentation.\n     +    Hook authors can inspect the computed result with ordinary tools:\n     +\n     +      GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n     +\n     +    without needing to interpret pathspec or mode flags like \"-u\" or\n     +    \"--renormalize\" -- the proposed index already reflects their effect.\n     +\n     +    The implementation creates a temporary copy of the index via the\n     +    tempfile API when find_hook(\"pre-add\") reports a hook is present,\n     +    then lets all staging proceed normally. At the finish label,\n     +    write_locked_index() writes the proposed index to the lockfile\n     +    without COMMIT_LOCK. If the hook approves, commit_lock_file()\n     +    atomically replaces the index. If the hook rejects,\n     +    rollback_lock_file() discards the lockfile and the original index\n     +    is left unchanged. When no hook is installed, the existing\n     +    write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still\n     +    taken.\n     +\n     +    The hook is bypassed with \"--no-verify\" and is not invoked for\n     +    --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n     +    which stages through its own code path.\n     +\n     +    Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson\n     +    and Makefile lists.\n      \n          Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n      \n     @@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If yo\n       filename of an ignored file, `git add` will fail with a list of ignored\n       files. Otherwise it will silently ignore the file.\n       \n     -+A pre-add hook can be run to inspect or reject the add operation before\n     -+it stages files. See linkgit:githooks[5] for details.\n     ++A pre-add hook can be run to inspect or reject the proposed index update\n     ++after `git add` computes staging and writes it to the index lockfile,\n     ++but before writing it to the final index. See linkgit:githooks[5].\n      +\n       Please see linkgit:git-commit[1] for alternative ways to add content to a\n       commit.\n     @@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com\n      +~~~~~~~\n      +\n      +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n     -+`--no-verify` option. This hook is not invoked for `--interactive`, `--patch`,\n     -+`--edit`, or `--dry-run`. It takes no parameters, and is invoked after pathspec\n     -+validation and before any files are staged. Exiting with a non-zero status\n     -+from this script causes the `git add` command to abort without modifying the\n     -+index.\n     ++`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n     ++`--edit`, or `--dry-run`.\n      +\n     -+This hook is invoked with the environment variable `GIT_INDEX_FILE`\n     -+which points to the index file. This allows the hook to inspect what\n     -+files would be staged before the operation proceeds.\n     ++It takes two parameters: the path to a copy of the index before this\n     ++invocation of `git add`, and the path to the lockfile containing the\n     ++proposed index after staging. It does not read from standard input.\n     ++If no index exists yet, the first parameter names a path that does not\n     ++exist and should be treated as an empty index. No special environment\n     ++variables are set. The hook is invoked after the index has been updated\n     ++in memory and written to the lockfile, but before it is committed to the\n     ++final location.\n      +\n     -+This hook is not invoked by `git commit -a` or `git commit --include` which\n     -+still can run the pre-commit hook, providing a control point at commit time.\n     ++Exiting with a non-zero status causes `git add` to abort and leaves the\n     ++index unchanged. Exiting with zero status causes the staged changes to\n     ++take effect.\n     ++\n     ++This hook can be used to prevent staging of files based on names, content,\n     ++or sizes (e.g., to block `.env` files, secret keys, or large files).\n     ++\n     ++This hook is not invoked by `git commit -a` or `git commit --include`\n     ++which still can run the pre-commit hook, providing a control point at\n     ++commit time.\n      +\n       pre-commit\n       ~~~~~~~~~~\n     @@ builtin/add.c\n       #include \"submodule.h\"\n       #include \"add-interactive.h\"\n      +#include \"hook.h\"\n     ++#include \"copy.h\"\n       \n       static const char * const builtin_add_usage[] = {\n       \tN_(\"git add [<options>] [--] <pathspec>...\"),\n     @@ builtin/add.c: static struct option builtin_add_options[] = {\n       \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n       \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n       \t\t   N_(\"override the executable bit of the listed files\")),\n     +@@ builtin/add.c: int cmd_add(int argc,\n     + \tchar *ps_matched = NULL;\n     + \tstruct lock_file lock_file = LOCK_INIT;\n     + \tstruct odb_transaction *transaction;\n     ++\tint run_pre_add = 0;\n     ++\tstruct tempfile *orig_index = NULL;\n     ++\tchar *orig_index_path = NULL;\n     + \n     + \trepo_config(repo, add_config, NULL);\n     + \n      @@ builtin/add.c: int cmd_add(int argc,\n       \t\tstring_list_clear(&only_match_skip_worktree, 0);\n       \t}\n       \n     -+\tif (!show_only && !no_verify) {\n     ++\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n     ++\t\tint fd_in, status;\n     ++\t\tconst char *index_file = repo_get_index_file(repo);\n     ++\t\tchar *template;\n     ++\n     ++\t\trun_pre_add = 1;\n     ++\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n     ++\t\torig_index = xmks_tempfile(template);\n     ++\t\tfree(template);\n     ++\n     ++\t\tfd_in = open(index_file, O_RDONLY);\n     ++\t\tif (fd_in >= 0) {\n     ++\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n     ++\t\t\tif (close(fd_in))\n     ++\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n     ++\t\t\tif (close_tempfile_gently(orig_index))\n     ++\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n     ++\t\t\tif (status < 0)\n     ++\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n     ++\t\t} else if (errno == ENOENT) {\n     ++\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n     ++\t\t\tif (delete_tempfile(&orig_index))\n     ++\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n     ++\t\t} else {\n     ++\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n     ++\t\t}\n     ++\t}\n     ++\n     + \ttransaction = odb_transaction_begin(repo->objects);\n     + \n     + \tps_matched = xcalloc(pathspec.nr, 1);\n     +@@ builtin/add.c: int cmd_add(int argc,\n     + \t\t\t\t\t\t  include_sparse, flags);\n     + \n     + \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n     +-\t    report_path_error(ps_matched, &pathspec))\n     ++\t    report_path_error(ps_matched, &pathspec)) {\n     ++\t\tif (orig_index)\n     ++\t\t\tdelete_tempfile(&orig_index);\n     ++\t\tfree(orig_index_path);\n     + \t\texit(128);\n     ++\t}\n     + \n     + \tif (add_new_files)\n     + \t\texit_status |= add_files(repo, &dir, flags);\n     +@@ builtin/add.c: int cmd_add(int argc,\n     + \todb_transaction_commit(transaction);\n     + \n     + finish:\n     +-\tif (write_locked_index(repo->index, &lock_file,\n     +-\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n     +-\t\tdie(_(\"unable to write new index file\"));\n     ++\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n      +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n      +\n     -+\t\tstrvec_pushf(&opt.env, \"GIT_INDEX_FILE=%s\",\n     -+\t\t\t     repo_get_index_file(repo));\n     ++\t\tif (write_locked_index(repo->index, &lock_file, 0))\n     ++\t\t\tdie(_(\"unable to write new index file\"));\n     ++\n     ++\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n     ++\t\t\t\t\t     orig_index_path);\n     ++\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n      +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n     ++\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n      +\t\t\texit_status = 1;\n     -+\t\t\tgoto finish;\n     ++\t\t} else {\n     ++\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n     ++\t\t\t\tdie(_(\"unable to write new index file\"));\n      +\t\t}\n     ++\t} else {\n     ++\t\tif (write_locked_index(repo->index, &lock_file,\n     ++\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n     ++\t\t\tdie(_(\"unable to write new index file\"));\n      +\t}\n      +\n     - \ttransaction = odb_transaction_begin(repo->objects);\n     ++\tdelete_tempfile(&orig_index);\n     ++\tfree(orig_index_path);\n       \n     - \tps_matched = xcalloc(pathspec.nr, 1);\n     + \tfree(ps_matched);\n     + \tdir_clear(&dir);\n     +\n     + ## t/meson.build ##\n     +@@ t/meson.build: integration_tests = [\n     +   't3703-add-magic-pathspec.sh',\n     +   't3704-add-pathspec-file.sh',\n     +   't3705-add-sparse-checkout.sh',\n     ++  't3706-pre-add-hook.sh',\n     +   't3800-mktag.sh',\n     +   't3900-i18n-commit.sh',\n     +   't3901-i18n-patch.sh',\n      \n       ## t/t3706-pre-add-hook.sh (new) ##\n      @@\n     @@ t/t3706-pre-add-hook.sh (new)\n      +\ttest_path_is_missing actual\n      +'\n      +\n     -+test_expect_success 'hook receives GIT_INDEX_FILE environment variable' '\n     -+\ttest_when_finished \"rm -f actual expected\" &&\n     -+\techo \"hook-saw-env\" >expected &&\n     ++test_expect_success 'hook receives original and proposed index as arguments' '\n     ++\ttest_when_finished \"rm -f tracked expected hook-ran\" &&\n     ++\techo \"initial\" >tracked &&\n     ++\tgit add tracked &&\n     ++\tgit commit -m \"initial\" &&\n      +\ttest_hook pre-add <<-\\EOF &&\n     -+\tif test -z \"$GIT_INDEX_FILE\"\n     -+\tthen\n     -+\t\techo hook-missing-env >>actual\n     -+\telse\n     -+\t\techo hook-saw-env >>actual\n     -+\tfi\n     ++\ttest $# -eq 2 &&\n     ++\ttest -f \"$1\" &&\n     ++\ttest -f \"$2\" &&\n     ++\techo pass >hook-ran\n      +\tEOF\n      +\n     -+\techo content >file &&\n     -+\tgit add file &&\n     -+\ttest_cmp expected actual\n     ++\techo \"modified\" >tracked &&\n     ++\tgit add tracked &&\n     ++\techo pass >expected &&\n     ++\ttest_cmp expected hook-ran\n     ++'\n     ++\n     ++test_expect_success 'hook handles first add with no existing index' '\n     ++\ttest_when_finished \"rm -rf no-index\" &&\n     ++\ttest_create_repo no-index &&\n     ++\techo ok >no-index/expected &&\n     ++\ttest_hook -C no-index pre-add <<-\\EOF &&\n     ++\ttest $# -eq 2 &&\n     ++\ttest ! -e \"$1\" &&\n     ++\ttest -f \"$2\" &&\n     ++\techo ok >hook-ran\n     ++\tEOF\n     ++\n     ++\techo first >no-index/file &&\n     ++\tgit -C no-index add file &&\n     ++\ttest_cmp no-index/expected no-index/hook-ran\n      +'\n      +\n     -+test_expect_success 'with --dry-run (show-only) the hook is not invoked' '\n     ++test_expect_success 'hook is not invoked with --dry-run (show-only)' '\n      +\ttest_when_finished \"rm -f actual\" &&\n      +\ttest_hook pre-add <<-\\EOF &&\n      +\techo should-not-run >>actual\n     @@ t/t3706-pre-add-hook.sh (new)\n      +\ttest_cmp expected actual\n      +'\n      +\n     ++test_expect_success 'hook can compare original and proposed index' '\n     ++\ttest_when_finished \"rm -f old-raw new-raw old-list new-list \\\n     ++\t\t\t    expected-old expected-new\" &&\n     ++\techo \"initial\" >file1 &&\n     ++\techo \"initial\" >file2 &&\n     ++\tgit add file1 file2 &&\n     ++\tgit commit -m \"initial\" &&\n     ++\techo \"staged-before\" >file1 &&\n     ++\tgit add file1 &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n     ++\tsort old-raw >old-list &&\n     ++\tsort new-raw >new-list\n     ++\tEOF\n     ++\n     ++\techo \"modified\" >file2 &&\n     ++\tgit add file2 &&\n     ++\techo file1 >expected-old &&\n     ++\tprintf \"%s\\n\" file1 file2 >expected-new &&\n     ++\ttest_cmp expected-old old-list &&\n     ++\ttest_cmp expected-new new-list\n     ++'\n     ++\n     ++test_expect_success 'hook rejection rolls back index unchanged' '\n     ++\ttest_when_finished \"rm -f file before after old-raw new-raw \\\n     ++\t\t\t    old-list new-list expected-old expected-new\" &&\n     ++\techo \"initial\" >file &&\n     ++\tgit add file &&\n     ++\tgit commit -m \"initial\" &&\n     ++\tgit diff --cached --name-only HEAD >before &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n     ++\tsort old-raw >old-list &&\n     ++\tsort new-raw >new-list &&\n     ++\texit 1\n     ++\tEOF\n     ++\n     ++\techo \"modified\" >file &&\n     ++\ttest_must_fail git add file &&\n     ++\tgit diff --cached --name-only HEAD >after &&\n     ++\ttest_cmp before after &&\n     ++\t: >expected-old &&\n     ++\techo file >expected-new &&\n     ++\ttest_cmp expected-old old-list &&\n     ++\ttest_cmp expected-new new-list\n     ++'\n     ++\n     ++test_expect_success 'hook example: block .env files' '\n     ++\ttest_when_finished \"rm -f .env safe.txt new-paths\" &&\n     ++\techo \"initial\" >base &&\n     ++\tgit add base &&\n     ++\tgit commit -m \"initial\" &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n     ++\twhile read path\n     ++\tdo\n     ++\t\tcase \"$path\" in\n     ++\t\t*.env|.env)\n     ++\t\t\techo \"error: $path must not be staged\" >&2\n     ++\t\t\texit 1\n     ++\t\t\t;;\n     ++\t\tesac\n     ++\tdone <new-paths\n     ++\tEOF\n     ++\n     ++\techo \"DB_PASS=secret\" >.env &&\n     ++\ttest_must_fail git add .env &&\n     ++\techo \"safe content\" >safe.txt &&\n     ++\tgit add safe.txt\n     ++'\n     ++\n     ++test_expect_success 'hook example: block secrets in content' '\n     ++\ttest_when_finished \"rm -f config.txt secret\" &&\n     ++\techo \"initial\" >config.txt &&\n     ++\tgit add config.txt &&\n     ++\tgit commit -m \"initial\" &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n     ++\tif grep -qE \"(API_KEY|SECRET_KEY|PRIVATE_KEY)=\" secret\n     ++\tthen\n     ++\t\techo \"error: staged content contains secrets\" >&2\n     ++\t\texit 1\n     ++\tfi\n     ++\tEOF\n     ++\n     ++\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n     ++\ttest_must_fail git add config.txt &&\n     ++\techo \"LOG_LEVEL=debug\" >config.txt &&\n     ++\tgit add config.txt\n     ++'\n     ++\n      +test_done\n\n\n Documentation/git-add.adoc  |  11 +-\n Documentation/githooks.adoc |  27 +++++\n builtin/add.c               |  68 ++++++++++-\n t/meson.build               |   1 +\n t/t3706-pre-add-hook.sh     | 227 ++++++++++++++++++++++++++++++++++++\n 5 files changed, 329 insertions(+), 5 deletions(-)\n create mode 100755 t/t3706-pre-add-hook.sh\n\ndiff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\nindex 6192daeb03..c864ce272d 100644\n--- a/Documentation/git-add.adoc\n+++ b/Documentation/git-add.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n [synopsis]\n git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n-\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n+\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n \t[--] [<pathspec>...]\n \n@@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n filename of an ignored file, `git add` will fail with a list of ignored\n files. Otherwise it will silently ignore the file.\n \n+A pre-add hook can be run to inspect or reject the proposed index update\n+after `git add` computes staging and writes it to the index lockfile,\n+but before writing it to the final index. See linkgit:githooks[5].\n+\n Please see linkgit:git-commit[1] for alternative ways to add content to a\n commit.\n \n@@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n \tDon't add the file(s), but only refresh their stat()\n \tinformation in the index.\n \n+`--no-verify`::\n+\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n+\tmore information about hooks.\n+\n `--ignore-errors`::\n \tIf some files could not be added because of errors indexing\n \tthem, do not abort the operation, but continue adding the\n@@ -451,6 +459,7 @@ linkgit:git-reset[1]\n linkgit:git-mv[1]\n linkgit:git-commit[1]\n linkgit:git-update-index[1]\n+linkgit:githooks[5]\n \n GIT\n ---\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 056553788d..7ef8718112 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -94,6 +94,33 @@ and is invoked after the patch is applied and a commit is made.\n This hook is meant primarily for notification, and cannot affect\n the outcome of `git am`.\n \n+pre-add\n+~~~~~~~\n+\n+This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n+`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n+`--edit`, or `--dry-run`.\n+\n+It takes two parameters: the path to a copy of the index before this\n+invocation of `git add`, and the path to the lockfile containing the\n+proposed index after staging. It does not read from standard input.\n+If no index exists yet, the first parameter names a path that does not\n+exist and should be treated as an empty index. No special environment\n+variables are set. The hook is invoked after the index has been updated\n+in memory and written to the lockfile, but before it is committed to the\n+final location.\n+\n+Exiting with a non-zero status causes `git add` to abort and leaves the\n+index unchanged. Exiting with zero status causes the staged changes to\n+take effect.\n+\n+This hook can be used to prevent staging of files based on names, content,\n+or sizes (e.g., to block `.env` files, secret keys, or large files).\n+\n+This hook is not invoked by `git commit -a` or `git commit --include`\n+which still can run the pre-commit hook, providing a control point at\n+commit time.\n+\n pre-commit\n ~~~~~~~~~~\n \ndiff --git a/builtin/add.c b/builtin/add.c\nindex 32709794b3..735c9a53fd 100644\n--- a/builtin/add.c\n+++ b/builtin/add.c\n@@ -25,6 +25,8 @@\n #include \"strvec.h\"\n #include \"submodule.h\"\n #include \"add-interactive.h\"\n+#include \"hook.h\"\n+#include \"copy.h\"\n \n static const char * const builtin_add_usage[] = {\n \tN_(\"git add [<options>] [--] <pathspec>...\"),\n@@ -36,6 +38,7 @@ static int take_worktree_changes;\n static int add_renormalize;\n static int pathspec_file_nul;\n static int include_sparse;\n+static int no_verify;\n static const char *pathspec_from_file;\n \n static int chmod_pathspec(struct repository *repo,\n@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {\n \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n+\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n \t\t   N_(\"override the executable bit of the listed files\")),\n@@ -391,6 +395,9 @@ int cmd_add(int argc,\n \tchar *ps_matched = NULL;\n \tstruct lock_file lock_file = LOCK_INIT;\n \tstruct odb_transaction *transaction;\n+\tint run_pre_add = 0;\n+\tstruct tempfile *orig_index = NULL;\n+\tchar *orig_index_path = NULL;\n \n \trepo_config(repo, add_config, NULL);\n \n@@ -576,6 +583,34 @@ int cmd_add(int argc,\n \t\tstring_list_clear(&only_match_skip_worktree, 0);\n \t}\n \n+\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n+\t\tint fd_in, status;\n+\t\tconst char *index_file = repo_get_index_file(repo);\n+\t\tchar *template;\n+\n+\t\trun_pre_add = 1;\n+\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n+\t\torig_index = xmks_tempfile(template);\n+\t\tfree(template);\n+\n+\t\tfd_in = open(index_file, O_RDONLY);\n+\t\tif (fd_in >= 0) {\n+\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n+\t\t\tif (close(fd_in))\n+\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n+\t\t\tif (close_tempfile_gently(orig_index))\n+\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n+\t\t\tif (status < 0)\n+\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n+\t\t} else if (errno == ENOENT) {\n+\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n+\t\t\tif (delete_tempfile(&orig_index))\n+\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n+\t\t} else {\n+\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n+\t\t}\n+\t}\n+\n \ttransaction = odb_transaction_begin(repo->objects);\n \n \tps_matched = xcalloc(pathspec.nr, 1);\n@@ -587,8 +622,12 @@ int cmd_add(int argc,\n \t\t\t\t\t\t  include_sparse, flags);\n \n \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n-\t    report_path_error(ps_matched, &pathspec))\n+\t    report_path_error(ps_matched, &pathspec)) {\n+\t\tif (orig_index)\n+\t\t\tdelete_tempfile(&orig_index);\n+\t\tfree(orig_index_path);\n \t\texit(128);\n+\t}\n \n \tif (add_new_files)\n \t\texit_status |= add_files(repo, &dir, flags);\n@@ -598,9 +637,30 @@ int cmd_add(int argc,\n \todb_transaction_commit(transaction);\n \n finish:\n-\tif (write_locked_index(repo->index, &lock_file,\n-\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n-\t\tdie(_(\"unable to write new index file\"));\n+\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\n+\t\tif (write_locked_index(repo->index, &lock_file, 0))\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\n+\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n+\t\t\t\t\t     orig_index_path);\n+\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n+\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n+\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n+\t\t\texit_status = 1;\n+\t\t} else {\n+\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n+\t\t\t\tdie(_(\"unable to write new index file\"));\n+\t\t}\n+\t} else {\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t}\n+\n+\tdelete_tempfile(&orig_index);\n+\tfree(orig_index_path);\n \n \tfree(ps_matched);\n \tdir_clear(&dir);\ndiff --git a/t/meson.build b/t/meson.build\nindex 459c52a489..d518596fcb 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -412,6 +412,7 @@ integration_tests = [\n   't3703-add-magic-pathspec.sh',\n   't3704-add-pathspec-file.sh',\n   't3705-add-sparse-checkout.sh',\n+  't3706-pre-add-hook.sh',\n   't3800-mktag.sh',\n   't3900-i18n-commit.sh',\n   't3901-i18n-patch.sh',\ndiff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\nnew file mode 100755\nindex 0000000000..5ff7161f9d\n--- /dev/null\n+++ b/t/t3706-pre-add-hook.sh\n@@ -0,0 +1,227 @@\n+#!/bin/sh\n+\n+test_description='pre-add hook tests\n+\n+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).'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'with no hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success POSIXPERM 'with non-executable hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\tchmod -x .git/hooks/pre-add &&\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success '--no-verify with no hook' '\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'with succeeding hook' '\n+\ttest_when_finished \"rm -f actual expected\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add-rejected >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\ttest_must_fail git add file\n+'\n+\n+test_expect_success '--no-verify with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'hook receives original and proposed index as arguments' '\n+\ttest_when_finished \"rm -f tracked expected hook-ran\" &&\n+\techo \"initial\" >tracked &&\n+\tgit add tracked &&\n+\tgit commit -m \"initial\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\ttest $# -eq 2 &&\n+\ttest -f \"$1\" &&\n+\ttest -f \"$2\" &&\n+\techo pass >hook-ran\n+\tEOF\n+\n+\techo \"modified\" >tracked &&\n+\tgit add tracked &&\n+\techo pass >expected &&\n+\ttest_cmp expected hook-ran\n+'\n+\n+test_expect_success 'hook handles first add with no existing index' '\n+\ttest_when_finished \"rm -rf no-index\" &&\n+\ttest_create_repo no-index &&\n+\techo ok >no-index/expected &&\n+\ttest_hook -C no-index pre-add <<-\\EOF &&\n+\ttest $# -eq 2 &&\n+\ttest ! -e \"$1\" &&\n+\ttest -f \"$2\" &&\n+\techo ok >hook-ran\n+\tEOF\n+\n+\techo first >no-index/file &&\n+\tgit -C no-index add file &&\n+\ttest_cmp no-index/expected no-index/hook-ran\n+'\n+\n+test_expect_success 'hook is not invoked with --dry-run (show-only)' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --dry-run file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'hook is invoked with git add -u' '\n+\ttest_when_finished \"rm -f actual expected file\" &&\n+\techo \"initial\" >file &&\n+\tgit add file &&\n+\tgit commit -m \"initial\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo modified >file &&\n+\tgit add -u &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'hook can compare original and proposed index' '\n+\ttest_when_finished \"rm -f old-raw new-raw old-list new-list \\\n+\t\t\t    expected-old expected-new\" &&\n+\techo \"initial\" >file1 &&\n+\techo \"initial\" >file2 &&\n+\tgit add file1 file2 &&\n+\tgit commit -m \"initial\" &&\n+\techo \"staged-before\" >file1 &&\n+\tgit add file1 &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n+\tsort old-raw >old-list &&\n+\tsort new-raw >new-list\n+\tEOF\n+\n+\techo \"modified\" >file2 &&\n+\tgit add file2 &&\n+\techo file1 >expected-old &&\n+\tprintf \"%s\\n\" file1 file2 >expected-new &&\n+\ttest_cmp expected-old old-list &&\n+\ttest_cmp expected-new new-list\n+'\n+\n+test_expect_success 'hook rejection rolls back index unchanged' '\n+\ttest_when_finished \"rm -f file before after old-raw new-raw \\\n+\t\t\t    old-list new-list expected-old expected-new\" &&\n+\techo \"initial\" >file &&\n+\tgit add file &&\n+\tgit commit -m \"initial\" &&\n+\tgit diff --cached --name-only HEAD >before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n+\tsort old-raw >old-list &&\n+\tsort new-raw >new-list &&\n+\texit 1\n+\tEOF\n+\n+\techo \"modified\" >file &&\n+\ttest_must_fail git add file &&\n+\tgit diff --cached --name-only HEAD >after &&\n+\ttest_cmp before after &&\n+\t: >expected-old &&\n+\techo file >expected-new &&\n+\ttest_cmp expected-old old-list &&\n+\ttest_cmp expected-new new-list\n+'\n+\n+test_expect_success 'hook example: block .env files' '\n+\ttest_when_finished \"rm -f .env safe.txt new-paths\" &&\n+\techo \"initial\" >base &&\n+\tgit add base &&\n+\tgit commit -m \"initial\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n+\twhile read path\n+\tdo\n+\t\tcase \"$path\" in\n+\t\t*.env|.env)\n+\t\t\techo \"error: $path must not be staged\" >&2\n+\t\t\texit 1\n+\t\t\t;;\n+\t\tesac\n+\tdone <new-paths\n+\tEOF\n+\n+\techo \"DB_PASS=secret\" >.env &&\n+\ttest_must_fail git add .env &&\n+\techo \"safe content\" >safe.txt &&\n+\tgit add safe.txt\n+'\n+\n+test_expect_success 'hook example: block secrets in content' '\n+\ttest_when_finished \"rm -f config.txt secret\" &&\n+\techo \"initial\" >config.txt &&\n+\tgit add config.txt &&\n+\tgit commit -m \"initial\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n+\tif grep -qE \"(API_KEY|SECRET_KEY|PRIVATE_KEY)=\" secret\n+\tthen\n+\t\techo \"error: staged content contains secrets\" >&2\n+\t\texit 1\n+\tfi\n+\tEOF\n+\n+\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n+\ttest_must_fail git add config.txt &&\n+\techo \"LOG_LEVEL=debug\" >config.txt &&\n+\tgit add config.txt\n+'\n+\n+test_done\n\nbase-commit: b2826b52eb7caff9f4ed6e85ec45e338bf02ad09\n-- \ngitgitgadget\n"},{"id":"535774","messageId":"LQvDJSyBjBqEvnPMAY4rI3oAHHSNnBZmHV9vfKbeSFeLEIO7kAFMg8CWM9e81_QKCuoL0_a7qMMUwmcLK9Aut9abH0fqpKB5g6KOEya-n7I=@pm.me","threadId":"64972","inReplyTo":"xmqq8qd0zan1.fsf@gitster.g","subject":"[PATCH] RE: add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-02-11T15:16:30Z","receivedAt":"2026-02-11T15:16:42Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"Thank you for the thorough feedback -- v2 follows the architecture you outlined.\n\nThe hook now runs after staging is computed, receiving two positional arguments: a temporary copy of the original index ($1) and the lockfile containing the proposed index ($2). Hook authors inspect the computed result directly.\n\nOne trade-off: since staging must complete to produce $2, blobs are written to the object store before the hook fires. I think it's worth it though for the reasons you mentioned re: hook authors. \n\nThe CI failure is a result of an ar/parallel-hooks conflict. \n\nAppreciate the time, effort, and thought you put into review and feedback. Excited to hear back\n\nChandra Kethi-Reddy\n\nSent with Proton Mail secure email.\n\nOn Wednesday, February 11th, 2026 at 12:31 AM, Junio C Hamano <gitster@pobox.com> wrote:\n\n> Junio C Hamano <gitster@pobox.com> writes:\n> \n> > The hook takes no clue from anything derived from the command line,\n> > not even the pathspec (or list of individual paths computed using\n> > the pathspec by the command) or the mode of operation like '-u' or\n> > '--renormalize'.  I am not sure how effective a decision the invoked\n> > hook can make to approve or deny in this lack of information.\n> \n> And I do not necessarily suggest passing the pathspec arguments or\n> command line options that the \"git add\" command received from its\n> caller down to the hook, which will force hook authors to emulate\n> what \"git add\" would do to these arguments and options, and they\n> will certainly get it wrong.\n> \n> I wonder if we can split write_locked_index() into two so that\n> writing out the in-core index to the temporary/lockfile can happen\n> separately from the call to commit_locked_index().  If we can do so,\n> then the following would become a viable and better implementation\n> of this new feature to run the \"pre-add\" hook:\n> \n>  * Determine if we will need to run this \"pre-add\" hook, at the\n>    location in the code you addded the run_hooks_opt() invocation,\n>    but do *NOT* run any hook there yet.\n> \n>  * Instead, create a temporary copy of the index file if the above\n>    says \"Yes, we are going to run the hook\".\n> \n>  * Let the code path to update the in-core index, i.e., letting\n>    everythning up to the \"finish:\" label to run normally.\n> \n>  * Perform the first-half of the write_locked_index(), writing the\n>    new index contents into the lockfile, but stopping before\n>    committing it to the final name.\n> \n>  * If we are running the hook, run it with two arguments, the name\n>    of the temporary copy of the original index we created earlier,\n>    and the name of this lockfile that has the proposed contents of\n>    the index if the hook allowed \"git add\" to proceed.\n> \n>  * If we ran the hook and hook succeeded, or if we did not have to\n>    run the hook at all, then commit the lockfile.  Otherwise abort\n>    the \"git add\" command and rollback_lock_file().\n> \n>  * Remove the temporary file we created earlier (if any).\n> \n> Your hooks can \"GIT_INDEX_FILE=$1 git diff --cached --name-only\" to\n> find out which paths already had changes added before this\n> invocation of \"git add\", and similarly using $2 get the list of\n> paths that will add further changes with this invocation.  The\n> latter set of paths you can inspect to see if you like the\n> additional changes brought in, perhaps like\n> \n>     #!/bin/sh\n>     paths=$(GIT_INDEX_FILE=$2 git diff --cached --name-only)\n>     GIT_INDEX_FILE=$1 git diff $paths >patch.txt\n> \n>     if grep \"^+.*secret\" patch.txt\n>     then\n>         echo \"do not divulge company secret!\" >&2\n> \texit 1\n>     fi\n> \n> or something.\n> \n> \n"},{"id":"535795","messageId":"xmqqseb7rre9.fsf@gitster.g","threadId":"64972","inReplyTo":"pull.2045.v2.git.1770822312474.gitgitgadget@gmail.com","subject":"Re: [PATCH v2] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-11T19:50:38Z","receivedAt":"2026-02-11T19:50:41Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\n> index 6192daeb03..c864ce272d 100644\n> --- a/Documentation/git-add.adoc\n> +++ b/Documentation/git-add.adoc\n> @@ -10,7 +10,7 @@ SYNOPSIS\n>  [synopsis]\n>  git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n>  \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n> -\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n> +\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n>  \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n>  \t[--] [<pathspec>...]\n>  \n> @@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n>  filename of an ignored file, `git add` will fail with a list of ignored\n>  files. Otherwise it will silently ignore the file.\n>  \n> +A pre-add hook can be run to inspect or reject the proposed index update\n> +after `git add` computes staging and writes it to the index lockfile,\n> +but before writing it to the final index. See linkgit:githooks[5].\n>\n> +`--no-verify`::\n> +\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n> +\tmore information about hooks.\n\nI'll leave it up to others to comment on and make concrete\nsuggestions for the formatting and markups, but the word pre-add the\nusers must use verbatim that is not marked up in any way would not\nlook good in the documentation.\n\nIs it and will it always be only the pre-add hook that this option\nwill bypass, or if we ever add another hook that decides to interfere,\nwill that hook also be turned off with this option?  This reads like\nthe former, but the intent would be the latter, no?\n\nI'll also leve it up to others (including the original author of the\npatch) to propose a better wording here, as I am not good at naming\nthings ;-)\n\n\n> +pre-add\n> +~~~~~~~\n> +\n> +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n> +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> +`--edit`, or `--dry-run`.\n> +\n> +It takes two parameters: the path to a copy of the index before this\n> +invocation of `git add`, and the path to the lockfile containing the\n> +proposed index after staging. It does not read from standard input.\n> +If no index exists yet, the first parameter names a path that does not\n> +exist and should be treated as an empty index. No special environment\n> +variables are set. The hook is invoked after the index has been updated\n\nWhat are \"special environment variables\"?  What happens, for\nexample, if the end user has an \"special environment variable\" set\nand exported when running \"git add\"---are you unexporting them?\nE.g., Does GIT_INDEX_FILE environment variable visible to the hook\nwhen you do this ...\n\n    $ GIT_INDEX_FILE=.git/alt-index git add .\n\n... and if so, what value does it have?\n\nIn other words, is it worth spelling this \"special environment\nvariables\" thing out?\n\n> +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> +\t\tint fd_in, status;\n> +\t\tconst char *index_file = repo_get_index_file(repo);\n> +\t\tchar *template;\n> +\n> +\t\trun_pre_add = 1;\n> +\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n> +\t\torig_index = xmks_tempfile(template);\n> +\t\tfree(template);\n> +\n> +\t\tfd_in = open(index_file, O_RDONLY);\n> +\t\tif (fd_in >= 0) {\n> +\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n> +\t\t\tif (close(fd_in))\n> +\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n> +\t\t\tif (close_tempfile_gently(orig_index))\n> +\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n> +\t\t\tif (status < 0)\n> +\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n> +\t\t} else if (errno == ENOENT) {\n> +\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n> +\t\t\tif (delete_tempfile(&orig_index))\n> +\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n> +\t\t} else {\n> +\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n> +\t\t}\n> +\t}\n\nDo we really need to create a copy of the file?  I am just asking\nwithout knowing the answer myself, but given that the general\narchitecture of file writing used in our codebase, which is to (1)\nprepare a new temporary file, (2) write new contents to that\ntemporary file, and then finally (3) rename the temporary file to\nthe final location, I would expect that between the time the control\npasses this point and the latter half of write_locked_index() calls\ncommit_locked_index(), the original index file would not be touched\nby anybody, and can be readable by the hook.\n\n> +\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n> +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> +\n> +\t\tif (write_locked_index(repo->index, &lock_file, 0))\n> +\t\t\tdie(_(\"unable to write new index file\"));\n\nThis mimics the pattern used in builtin/commit.c:prepare_index()\nthat populates the index file (the real one, when making a\nnon-partial commit, or the temporary one when making a partial\ncommit), closes it, and let us later commit or roll back depending\non what happens in between.  Looks sensible (but I have to admit\nthat I may have missed resource leakage etc., as I didn't seriously\nlook for such flaws).\n\nShouldn't the die() message mirror the wording used there, i.e.,\n\"unable to create temporary index\" or something, or is this fine, as\nit will become the new index file once the hook approves?  I dunno.\n\nThanks.\n\n> +\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n> +\t\t\t\t\t     orig_index_path);\n> +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n> +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n> +\t\t\texit_status = 1;\n> +\t\t} else {\n> +\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n> +\t\t\t\tdie(_(\"unable to write new index file\"));\n> +\t\t}\n> +\t} else {\n> +\t\tif (write_locked_index(repo->index, &lock_file,\n> +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> +\t\t\tdie(_(\"unable to write new index file\"));\n> +\t}\n"},{"id":"535800","messageId":"2kX5wTQeOz3VPzUT6QiH_KyB9RMMtf8L3I8N6WtVWHaVQ1ZguBTaqAqFcFgOGpCqv-RJyALKlsENx-g7E3DMx3TzCfZoaRtPEpoDyx6d9kg=@pm.me","threadId":"64972","inReplyTo":"xmqqseb7rre9.fsf@gitster.g","subject":"Re: [PATCH v2] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-02-11T21:11:15Z","receivedAt":"2026-02-11T21:11:26Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"> the word pre-add ... would not look good\n\nOriginally, I wanted to call these pre-staging hooks. The ugly pre-add wording was an artifact of my attempt to narrow the scope. The goal here was to be as conservative as possible because I thought this concept would be more controversial. This implementation didn't contain hooks for stash/merge/rebase/cherry-pick, which modify the index in their own ways. It wasn't a hook for `commit -a` nor reset/checkout/restore either. I felt it excessively ambitious to name this the pre-staging hook, especially as my first contribution.\n\nIdeally however, I think there should be a category of hooks called pre-staging hooks, with this as the flagship one, and it would make sense, for both aesthetic and future-proofing reasons, for the githooks docs to use that phrasing. \n\n> Is it and will it always be only the pre-add hook that this option\n> will bypass, or if we ever add another hook that decides to interfere,\n> will that hook also be turned off with this option?  This reads like\n> the former, but the intent would be the latter, no?\n\nAs it stands, the no-verify flag is only used in the guard for the \"pre-add\" hook given the limited scope I aimed for. The implementation could be futureproofed in a way where a string could be passed to the --no-verify flag, each with a unique boolean to guard different hooks. If the flag is set but no strings are passed, then we can assume the user wants no hooks to run, and all of them can be disabled. I thought it overengineering to add something like that in the initial commit, but am open to doing so.\n\n> What is a special environment variable?\n\nThat's hilarious. I suppose there's nothing \"special\" about them, I only meant to say that no unexpected environment variables were being set or unset by the implementation. It was mostly to distinguish this from the original implementation that passed GIT_INDEX_FILE as an env-var which you correctly noted didn't even make sense. In hindsight, I don't see much value in spelling this out unless anyone thinks it would help users distinguish from pre-commit hooks in some useful way.\n\n> Do we really need to create a copy of this [index] file? \n\nLockfile protocol should prevent index from being modified. It probably could be as easy as 1) write proposed index -> index.lock and run the hook with $1=index $2=index.lock. Good point. I'll try this out and push it if it works.\n\n> Shouldn't the die() message mirror the wording used there, i.e.,\n> \"unable to create temporary index\" or something, or is this fine, as\n> it will become the new index file once the hook approves? \n\nAnswer depends on how the rewrite without index-copying goes. I'll be more conscientious of die messaging in the next commit.\n\nIn all, I'd like hooks for pre-staging to be the operative concept here, not pre-add, for more reasons than just the word's poor aesthetics. With interest/approval, I can change the --no-verify implementation to be more generic, although I'm not sure if it's worth actually adding any other pre-staging hooks yet because I haven't seen anyone ask for anything besides gates before add. \n\nThanks again\n\nChandra Kethi-Reddy\n\nSent with Proton Mail secure email.\n\nOn Thursday, February 12th, 2026 at 1:20 AM, Junio C Hamano <gitster@pobox.com> wrote:\n\n> \"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com>\n> writes:\n> \n> > diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\n> > index 6192daeb03..c864ce272d 100644\n> > --- a/Documentation/git-add.adoc\n> > +++ b/Documentation/git-add.adoc\n> > @@ -10,7 +10,7 @@ SYNOPSIS\n> >  [synopsis]\n> >  git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n> >  \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n> > -\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n> > +\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n> >  \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n> >  \t[--] [<pathspec>...]\n> >\n> > @@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n> >  filename of an ignored file, `git add` will fail with a list of ignored\n> >  files. Otherwise it will silently ignore the file.\n> >\n> > +A pre-add hook can be run to inspect or reject the proposed index update\n> > +after `git add` computes staging and writes it to the index lockfile,\n> > +but before writing it to the final index. See linkgit:githooks[5].\n> >\n> > +`--no-verify`::\n> > +\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n> > +\tmore information about hooks.\n> \n> I'll leave it up to others to comment on and make concrete\n> suggestions for the formatting and markups, but the word pre-add the\n> users must use verbatim that is not marked up in any way would not\n> look good in the documentation.\n> \n> Is it and will it always be only the pre-add hook that this option\n> will bypass, or if we ever add another hook that decides to interfere,\n> will that hook also be turned off with this option?  This reads like\n> the former, but the intent would be the latter, no?\n> \n> I'll also leve it up to others (including the original author of the\n> patch) to propose a better wording here, as I am not good at naming\n> things ;-)\n> \n> \n> > +pre-add\n> > +~~~~~~~\n> > +\n> > +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n> > +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> > +`--edit`, or `--dry-run`.\n> > +\n> > +It takes two parameters: the path to a copy of the index before this\n> > +invocation of `git add`, and the path to the lockfile containing the\n> > +proposed index after staging. It does not read from standard input.\n> > +If no index exists yet, the first parameter names a path that does not\n> > +exist and should be treated as an empty index. No special environment\n> > +variables are set. The hook is invoked after the index has been updated\n> \n> What are \"special environment variables\"?  What happens, for\n> example, if the end user has an \"special environment variable\" set\n> and exported when running \"git add\"---are you unexporting them?\n> E.g., Does GIT_INDEX_FILE environment variable visible to the hook\n> when you do this ...\n> \n>     $ GIT_INDEX_FILE=.git/alt-index git add .\n> \n> ... and if so, what value does it have?\n> \n> In other words, is it worth spelling this \"special environment\n> variables\" thing out?\n> \n> > +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> > +\t\tint fd_in, status;\n> > +\t\tconst char *index_file = repo_get_index_file(repo);\n> > +\t\tchar *template;\n> > +\n> > +\t\trun_pre_add = 1;\n> > +\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n> > +\t\torig_index = xmks_tempfile(template);\n> > +\t\tfree(template);\n> > +\n> > +\t\tfd_in = open(index_file, O_RDONLY);\n> > +\t\tif (fd_in >= 0) {\n> > +\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n> > +\t\t\tif (close(fd_in))\n> > +\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n> > +\t\t\tif (close_tempfile_gently(orig_index))\n> > +\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n> > +\t\t\tif (status < 0)\n> > +\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n> > +\t\t} else if (errno == ENOENT) {\n> > +\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n> > +\t\t\tif (delete_tempfile(&orig_index))\n> > +\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n> > +\t\t} else {\n> > +\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n> > +\t\t}\n> > +\t}\n> \n> Do we really need to create a copy of the file?  I am just asking\n> without knowing the answer myself, but given that the general\n> architecture of file writing used in our codebase, which is to (1)\n> prepare a new temporary file, (2) write new contents to that\n> temporary file, and then finally (3) rename the temporary file to\n> the final location, I would expect that between the time the control\n> passes this point and the latter half of write_locked_index() calls\n> commit_locked_index(), the original index file would not be touched\n> by anybody, and can be readable by the hook.\n> \n> > +\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n> > +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> > +\n> > +\t\tif (write_locked_index(repo->index, &lock_file, 0))\n> > +\t\t\tdie(_(\"unable to write new index file\"));\n> \n> This mimics the pattern used in builtin/commit.c:prepare_index()\n> that populates the index file (the real one, when making a\n> non-partial commit, or the temporary one when making a partial\n> commit), closes it, and let us later commit or roll back depending\n> on what happens in between.  Looks sensible (but I have to admit\n> that I may have missed resource leakage etc., as I didn't seriously\n> look for such flaws).\n> \n> Shouldn't the die() message mirror the wording used there, i.e.,\n> \"unable to create temporary index\" or something, or is this fine, as\n> it will become the new index file once the hook approves?  I dunno.\n> \n> Thanks.\n> \n> > +\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n> > +\t\t\t\t\t     orig_index_path);\n> > +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n> > +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> > +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n> > +\t\t\texit_status = 1;\n> > +\t\t} else {\n> > +\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n> > +\t\t\t\tdie(_(\"unable to write new index file\"));\n> > +\t\t}\n> > +\t} else {\n> > +\t\tif (write_locked_index(repo->index, &lock_file,\n> > +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> > +\t\t\tdie(_(\"unable to write new index file\"));\n> > +\t}\n> \n> \n"},{"id":"535805","messageId":"xmqqfr77rn1t.fsf@gitster.g","threadId":"64972","inReplyTo":"2kX5wTQeOz3VPzUT6QiH_KyB9RMMtf8L3I8N6WtVWHaVQ1ZguBTaqAqFcFgOGpCqv-RJyALKlsENx-g7E3DMx3TzCfZoaRtPEpoDyx6d9kg=@pm.me","subject":"Re: [PATCH v2] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-11T21:24:30Z","receivedAt":"2026-02-11T21:24:33Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Chandra <Chandrakr@pm.me> writes:\n\n>> the word pre-add ... would not look good\n>\n> Originally, I wanted to call these pre-staging hooks.\n\nI was not talking about the choice of words.  If pre-commit\ninterferes before a commit is made in 'git commit', pre-add is a\nnatural phrase to use to interfere 'git add'.\n\nIt was a comment only on how it is typeset in the documentation,\ne.g., should it be `pre-add` (for verbatim), 'pre-add', _pre_add_,\netc.\n"},{"id":"535807","messageId":"oxAq83GHpc_Iuijrz10UxkyWknKQ0E3AilBFvKqNJg1OpO4ldNr5NUIsit3gSMjjO8dqG_t0FB4uAgYyrmLN4VGyx_ZeZOWh5qogrAiDWVc=@pm.me","threadId":"64972","inReplyTo":"xmqqfr77rn1t.fsf@gitster.g","subject":"Re: [PATCH v2] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-02-11T21:54:28Z","receivedAt":"2026-02-11T21:54:36Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"In the git-commit documentation, pre-commit is always verbatim. For consistency, pre-add typeset as verbatim makes sense.\n\n\nChandra Kethi-Reddy\n\nSent from Proton Mail for iOS.\n\n-------- Original Message --------\nOn Thursday, 02/12/26 at 02:56 Junio C Hamano <gitster@pobox.com> wrote:\nChandra <Chandrakr@pm.me> writes:\n\n>> the word pre-add ... would not look good\n>\n> Originally, I wanted to call these pre-staging hooks.\n\nI was not talking about the choice of words.  If pre-commit\ninterferes before a commit is made in 'git commit', pre-add is a\nnatural phrase to use to interfere 'git add'.\n\nIt was a comment only on how it is typeset in the documentation,\ne.g., should it be `pre-add` (for verbatim), 'pre-add', _pre_add_,\netc.\n\n\n"},{"id":"537062","messageId":"TjRZg9NlROW7rOZkb70ZxSqGHVSP91O5kV9uWyXR9Z41GyCSYDbY1xPBTEowgHkx5xZYVSR35eLxTdpkHRP0DxmtkZoJt1CoUjBzv9bQdCI=@pm.me","threadId":"64972","inReplyTo":"oxAq83GHpc_Iuijrz10UxkyWknKQ0E3AilBFvKqNJg1OpO4ldNr5NUIsit3gSMjjO8dqG_t0FB4uAgYyrmLN4VGyx_ZeZOWh5qogrAiDWVc=@pm.me","subject":"Re: [PATCH v3] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-02-25T02:15:31Z","receivedAt":"2026-02-25T02:15:42Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"Thanks again for the review. I prepared v3 with the lockfile argument model, a mixed-result gating fix, a 'post-index-change' contract fix, and docs updates.\n\n\nChandra Kethi-Reddy\n@archonphronesis:matrix.org\n\nSent from Proton Mail for iOS.\n\n-------- Original Message --------\nOn Thursday, 02/12/26 at 03:25 Chandra <Chandrakr@pm.me> wrote:\nIn the git-commit documentation, pre-commit is always verbatim. For consistency, pre-add typeset as verbatim makes sense.\n\n\nChandra Kethi-Reddy\n\nSent from Proton Mail for iOS.\n\n-------- Original Message --------\nOn Thursday, 02/12/26 at 02:56 Junio C Hamano <gitster@pobox.com> wrote:\nChandra <Chandrakr@pm.me> writes:\n\n>> the word pre-add ... would not look good\n>\n> Originally, I wanted to call these pre-staging hooks.\n\nI was not talking about the choice of words.  If pre-commit\ninterferes before a commit is made in 'git commit', pre-add is a\nnatural phrase to use to interfere 'git add'.\n\nIt was a comment only on how it is typeset in the documentation,\ne.g., should it be `pre-add` (for verbatim), 'pre-add', _pre_add_,\netc.\n\n\n\n\n"},{"id":"537270","messageId":"pull.2045.v3.git.1772171692465.gitgitgadget@gmail.com","threadId":"64972","inReplyTo":"pull.2045.v2.git.1770822312474.gitgitgadget@gmail.com","subject":"[PATCH v3] add: support pre-add hook","fromName":"Chandra Kethi-Reddy via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-27T05:54:52Z","receivedAt":"2026-02-27T05:55:09Z","isPatch":true,"sender":{"key":"name:Chandra Kethi-Reddy","avatar":null},"body":"From: Chandra Kethi-Reddy <chandrakr@pm.me>\n\n\"git add\" has no hook that lets users inspect what is about to be\nstaged. Users who want to reject certain paths or content must\nwrap the command in a shell alias or wait for pre-commit, which\nfires too late to prevent staging.\n\nIntroduce a \"pre-add\" hook that runs after \"git add\" computes the\nnew index state but before committing it to disk. The hook\nreceives two positional arguments:\n\n  $1 -- index path used by this invocation (may not exist yet)\n  $2 -- lockfile path containing proposed staged index state\n\nWhile the lockfile is active the current index path remains readable\nand unchanged, so a seperate copy is unnecessary. Hook authors can\ninspect the computed result with ordinary tools:\n\n  GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n\nwithout needing to interpret pathspec or mode flags as the proposed\nindex already reflects their effect.\n\nAt the finish label, write_locked_index() writes the proposed index\nto the lockfile without COMMIT_LOCK so commit_lock_file() can be\ncalled seperately after the hook runs. However, do_write_locked_index()\nunconditionally fires post-index-change after every write, and the\nexisting test suite (t7113) asserts that index.lock does not exist when\nthat hook fires. Tying the hook to COMMIT_LOCK would suppress it for\nother callers that depend on it after a non-committed write (e.g.,\nprepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK\nflag lets builtin/add.c suppress the automatic notification on just this\ncall, then emit post-index-change manually after commit_lock_file()\npublishes the new index. If the hook rejects, rollback_lock_file()\ndiscards the lockfile and the original index is left unchanged. When\nno hook is installed the existing write_locked_index(COMMIT_LOCK |\nSKIP_IF_UNCHANGED) path is taken.\n\nThe hook gate checks cache_changed regardless of exit_status so that\nmixed-result adds (e.g., a tracked modification combined with an\nignored path) still run the hook when index content changes.\n\nThe hook is bypassed with \"--no-verify\" and is not invoked for\n--interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\nwhich stages through its own code path.\n\nSigned-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n---\n    add: support pre-add hook\n    \n    \n    Summary\n    =======\n    \n     * v3 switches pre-add inputs to stable paths ($1 index, $2 lockfile)\n       and removes copy-specific tempfile logic\n     * v3 fixes mixed-result gating so the hook runs whenever index content\n       changed, even if git add returned non-zero\n     * v3 adds SKIP_INDEX_CHANGE_HOOK flag to write_locked_index() so that\n       post-index-change is not fired while the lockfile is still on disk\n    \n    \n    Notes\n    =====\n    \n     * This design intentionally trades ODB prevention for correctness of\n       hook inputs: blobs may already be written to object storage when the\n       hook runs, but hook rejection still leaves the on-disk index\n       unchanged\n     * AI Disclosure: Codex and Claude Code CLI were used to assist\n       drafting. All tests, code, and docs were committed by hand.\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v3\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v3\nPull-Request: https://github.com/gitgitgadget/git/pull/2045\n\nRange-diff vs v2:\n\n 1:  10244150e24 ! 1:  d0fb5f9da21 add: support pre-add hook\n     @@ Commit message\n          \"git add\" has no hook that lets users inspect what is about to be\n          staged. Users who want to reject certain paths or content must\n          wrap the command in a shell alias or wait for pre-commit, which\n     -    fires after staging is already done and objects may already be in\n     -    the object database.\n     +    fires too late to prevent staging.\n      \n          Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n          new index state but before committing it to disk. The hook\n     -    receives two arguments:\n     +    receives two positional arguments:\n      \n     -      $1 -- path to a temporary copy of the index before this \"git add\"\n     -      $2 -- path to the lockfile containing the proposed index\n     +      $1 -- index path used by this invocation (may not exist yet)\n     +      $2 -- lockfile path containing proposed staged index state\n      \n     -    $1 on first add can be a non-existent path representing an empty\n     -    index.\n     -\n     -    Hook authors can inspect the computed result with ordinary tools:\n     +    While the lockfile is active the current index path remains readable\n     +    and unchanged, so a seperate copy is unnecessary. Hook authors can\n     +    inspect the computed result with ordinary tools:\n      \n            GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n      \n     -    without needing to interpret pathspec or mode flags like \"-u\" or\n     -    \"--renormalize\" -- the proposed index already reflects their effect.\n     +    without needing to interpret pathspec or mode flags as the proposed\n     +    index already reflects their effect.\n     +\n     +    At the finish label, write_locked_index() writes the proposed index\n     +    to the lockfile without COMMIT_LOCK so commit_lock_file() can be\n     +    called seperately after the hook runs. However, do_write_locked_index()\n     +    unconditionally fires post-index-change after every write, and the\n     +    existing test suite (t7113) asserts that index.lock does not exist when\n     +    that hook fires. Tying the hook to COMMIT_LOCK would suppress it for\n     +    other callers that depend on it after a non-committed write (e.g.,\n     +    prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK\n     +    flag lets builtin/add.c suppress the automatic notification on just this\n     +    call, then emit post-index-change manually after commit_lock_file()\n     +    publishes the new index. If the hook rejects, rollback_lock_file()\n     +    discards the lockfile and the original index is left unchanged. When\n     +    no hook is installed the existing write_locked_index(COMMIT_LOCK |\n     +    SKIP_IF_UNCHANGED) path is taken.\n      \n     -    The implementation creates a temporary copy of the index via the\n     -    tempfile API when find_hook(\"pre-add\") reports a hook is present,\n     -    then lets all staging proceed normally. At the finish label,\n     -    write_locked_index() writes the proposed index to the lockfile\n     -    without COMMIT_LOCK. If the hook approves, commit_lock_file()\n     -    atomically replaces the index. If the hook rejects,\n     -    rollback_lock_file() discards the lockfile and the original index\n     -    is left unchanged. When no hook is installed, the existing\n     -    write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still\n     -    taken.\n     +    The hook gate checks cache_changed regardless of exit_status so that\n     +    mixed-result adds (e.g., a tracked modification combined with an\n     +    ignored path) still run the hook when index content changes.\n      \n          The hook is bypassed with \"--no-verify\" and is not invoked for\n          --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n          which stages through its own code path.\n      \n     -    Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson\n     -    and Makefile lists.\n     -\n          Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n      \n       ## Documentation/git-add.adoc ##\n     @@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If yo\n       filename of an ignored file, `git add` will fail with a list of ignored\n       files. Otherwise it will silently ignore the file.\n       \n     -+A pre-add hook can be run to inspect or reject the proposed index update\n     ++A `pre-add` hook can be run to inspect or reject the proposed index update\n      +after `git add` computes staging and writes it to the index lockfile,\n      +but before writing it to the final index. See linkgit:githooks[5].\n      +\n     @@ Documentation/git-add.adoc: for `git add --no-all <pathspec>...`, i.e. ignored r\n       \tinformation in the index.\n       \n      +`--no-verify`::\n     -+\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n     ++\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n      +\tmore information about hooks.\n      +\n       `--ignore-errors`::\n     @@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com\n      +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n      +`--edit`, or `--dry-run`.\n      +\n     -+It takes two parameters: the path to a copy of the index before this\n     -+invocation of `git add`, and the path to the lockfile containing the\n     -+proposed index after staging. It does not read from standard input.\n     -+If no index exists yet, the first parameter names a path that does not\n     -+exist and should be treated as an empty index. No special environment\n     -+variables are set. The hook is invoked after the index has been updated\n     -+in memory and written to the lockfile, but before it is committed to the\n     -+final location.\n     ++It takes two parameters: the path to the index file for this invocation\n     ++of `git add`, and the path to the lockfile containing the proposed\n     ++index after staging. It does not read from standard input. If no index\n     ++exists yet, the first parameter names a path that does not exist and\n     ++should be treated as an empty index.\n     ++\n     ++The hook is invoked after the index has been updated in memory and\n     ++written to the lockfile, but before it is committed to the final index\n     ++path. Exiting with a non-zero status causes `git add` to reject the\n     ++proposed state, roll back the lockfile, and leave the index unchanged.\n     ++Exiting with zero status allows the index update to be committed.\n      +\n     -+Exiting with a non-zero status causes `git add` to abort and leaves the\n     -+index unchanged. Exiting with zero status causes the staged changes to\n     -+take effect.\n     ++Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n     ++set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n     ++`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n      +\n      +This hook can be used to prevent staging of files based on names, content,\n      +or sizes (e.g., to block `.env` files, secret keys, or large files).\n      +\n      +This hook is not invoked by `git commit -a` or `git commit --include`\n     -+which still can run the pre-commit hook, providing a control point at\n     ++which still can run the `pre-commit` hook, providing a control point at\n      +commit time.\n      +\n       pre-commit\n     @@ builtin/add.c\n       #include \"submodule.h\"\n       #include \"add-interactive.h\"\n      +#include \"hook.h\"\n     -+#include \"copy.h\"\n     ++#include \"abspath.h\"\n       \n       static const char * const builtin_add_usage[] = {\n       \tN_(\"git add [<options>] [--] <pathspec>...\"),\n     @@ builtin/add.c: int cmd_add(int argc,\n       \tstruct lock_file lock_file = LOCK_INIT;\n       \tstruct odb_transaction *transaction;\n      +\tint run_pre_add = 0;\n     -+\tstruct tempfile *orig_index = NULL;\n      +\tchar *orig_index_path = NULL;\n       \n       \trepo_config(repo, add_config, NULL);\n     @@ builtin/add.c: int cmd_add(int argc,\n       \t}\n       \n      +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n     -+\t\tint fd_in, status;\n     -+\t\tconst char *index_file = repo_get_index_file(repo);\n     -+\t\tchar *template;\n     -+\n      +\t\trun_pre_add = 1;\n     -+\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n     -+\t\torig_index = xmks_tempfile(template);\n     -+\t\tfree(template);\n     -+\n     -+\t\tfd_in = open(index_file, O_RDONLY);\n     -+\t\tif (fd_in >= 0) {\n     -+\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n     -+\t\t\tif (close(fd_in))\n     -+\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n     -+\t\t\tif (close_tempfile_gently(orig_index))\n     -+\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n     -+\t\t\tif (status < 0)\n     -+\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n     -+\t\t} else if (errno == ENOENT) {\n     -+\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n     -+\t\t\tif (delete_tempfile(&orig_index))\n     -+\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n     -+\t\t} else {\n     -+\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n     -+\t\t}\n     ++\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n      +\t}\n      +\n       \ttransaction = odb_transaction_begin(repo->objects);\n     @@ builtin/add.c: int cmd_add(int argc,\n       \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n      -\t    report_path_error(ps_matched, &pathspec))\n      +\t    report_path_error(ps_matched, &pathspec)) {\n     -+\t\tif (orig_index)\n     -+\t\t\tdelete_tempfile(&orig_index);\n      +\t\tfree(orig_index_path);\n       \t\texit(128);\n      +\t}\n     @@ builtin/add.c: int cmd_add(int argc,\n      -\tif (write_locked_index(repo->index, &lock_file,\n      -\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n      -\t\tdie(_(\"unable to write new index file\"));\n     -+\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n     ++\tif (run_pre_add && repo->index->cache_changed) {\n      +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n      +\n     -+\t\tif (write_locked_index(repo->index, &lock_file, 0))\n     -+\t\t\tdie(_(\"unable to write new index file\"));\n     ++\t\tif (write_locked_index(repo->index, &lock_file,\n     ++\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n     ++\t\t\tdie(_(\"unable to write proposed index\"));\n      +\n     -+\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n     -+\t\t\t\t\t     orig_index_path);\n     ++\t\tstrvec_push(&opt.args, orig_index_path);\n      +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n      +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n      +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n      +\t\t\texit_status = 1;\n     ++\t\t} else if (commit_lock_file(&lock_file)) {\n     ++\t\t\tdie(_(\"unable to write new index file\"));\n      +\t\t} else {\n     -+\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n     -+\t\t\t\tdie(_(\"unable to write new index file\"));\n     ++\t\t\trun_hooks_l(repo, \"post-index-change\",\n     ++\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n     ++\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n     ++\t\t\t\t    NULL);\n      +\t\t}\n     ++\t\trepo->index->updated_workdir = 0;\n     ++\t\trepo->index->updated_skipworktree = 0;\n      +\t} else {\n      +\t\tif (write_locked_index(repo->index, &lock_file,\n      +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n      +\t\t\tdie(_(\"unable to write new index file\"));\n      +\t}\n      +\n     -+\tdelete_tempfile(&orig_index);\n      +\tfree(orig_index_path);\n       \n       \tfree(ps_matched);\n       \tdir_clear(&dir);\n      \n     + ## read-cache-ll.h ##\n     +@@ read-cache-ll.h: int is_index_unborn(struct index_state *);\n     + /* For use with `write_locked_index()`. */\n     + #define COMMIT_LOCK\t\t(1 << 0)\n     + #define SKIP_IF_UNCHANGED\t(1 << 1)\n     ++#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n     + \n     + /*\n     +  * Write the index while holding an already-taken lock. Close the lock,\n     +\n     + ## read-cache.c ##\n     +@@ read-cache.c: static int do_write_locked_index(struct index_state *istate,\n     + \telse\n     + \t\tret = close_lock_file_gently(lock);\n     + \n     +-\trun_hooks_l(the_repository, \"post-index-change\",\n     +-\t\t    istate->updated_workdir ? \"1\" : \"0\",\n     +-\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n     +-\tistate->updated_workdir = 0;\n     +-\tistate->updated_skipworktree = 0;\n     +-\n     ++\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n     ++\t\trun_hooks_l(the_repository, \"post-index-change\",\n     ++\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n     ++\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n     ++\t\tistate->updated_workdir = 0;\n     ++\t\tistate->updated_skipworktree = 0;\n     ++\t}\n     + \treturn ret;\n     + }\n     + \n     +\n       ## t/meson.build ##\n      @@ t/meson.build: integration_tests = [\n         't3703-add-magic-pathspec.sh',\n     @@ t/t3706-pre-add-hook.sh (new)\n      +\ttest_path_is_missing actual\n      +'\n      +\n     -+test_expect_success 'hook receives original and proposed index as arguments' '\n     -+\ttest_when_finished \"rm -f tracked expected hook-ran\" &&\n     -+\techo \"initial\" >tracked &&\n     -+\tgit add tracked &&\n     -+\tgit commit -m \"initial\" &&\n     ++test_expect_success 'setup for path-based tests' '\n     ++\tgit add file &&\n     ++\tgit commit -m \"initial\"\n     ++'\n     ++\n     ++test_expect_success 'hook receives index-path and lockfile-path arguments' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n     ++\t\t\t    expect-index expect-lockpath\" &&\n     ++\techo staged >staged &&\n     ++\tcat >expect-count <<-\\EOF &&\n     ++\t2\n     ++\tEOF\n      +\ttest_hook pre-add <<-\\EOF &&\n     -+\ttest $# -eq 2 &&\n     -+\ttest -f \"$1\" &&\n     -+\ttest -f \"$2\" &&\n     -+\techo pass >hook-ran\n     ++\techo \"$#\" >arg-count &&\n     ++\techo \"$1\" >arg-one &&\n     ++\techo \"$2\" >arg-two &&\n     ++\ttest \"$1\" != \"$2\" &&\n     ++\ttest -r \"$2\"\n      +\tEOF\n     ++\tgit add staged &&\n     ++\ttest_cmp expect-count arg-count &&\n     ++\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n     ++\ttest_cmp expect-index arg-one &&\n     ++\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n     ++\ttest_cmp expect-lockpath arg-two\n     ++'\n      +\n     -+\techo \"modified\" >tracked &&\n     -+\tgit add tracked &&\n     -+\techo pass >expected &&\n     -+\ttest_cmp expected hook-ran\n     ++test_expect_success 'hook rejection leaves final index unchanged' '\n     ++\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n     ++\tcp .git/index index.before &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\texit 1\n     ++\tEOF\n     ++\techo reject >reject &&\n     ++\ttest_must_fail git add reject &&\n     ++\ttest_cmp_bin index.before .git/index &&\n     ++\ttest_path_is_missing .git/index.lock\n      +'\n      +\n     -+test_expect_success 'hook handles first add with no existing index' '\n     -+\ttest_when_finished \"rm -rf no-index\" &&\n     -+\ttest_create_repo no-index &&\n     -+\techo ok >no-index/expected &&\n     -+\ttest_hook -C no-index pre-add <<-\\EOF &&\n     -+\ttest $# -eq 2 &&\n     ++test_expect_success 'missing pre-existing index path treated as empty' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n     ++\trm -f .git/index &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\techo \"$1\" >arg-one &&\n      +\ttest ! -e \"$1\" &&\n     -+\ttest -f \"$2\" &&\n     -+\techo ok >hook-ran\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n     ++\tsort after.raw >after\n      +\tEOF\n     -+\n     -+\techo first >no-index/file &&\n     -+\tgit -C no-index add file &&\n     -+\ttest_cmp no-index/expected no-index/hook-ran\n     ++\techo newfile >newfile &&\n     ++\tgit add newfile &&\n     ++\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n     ++\ttest_cmp expect-index arg-one &&\n     ++\tgrep \"^newfile$\" after &&\n     ++\tgrep \"^file$\" after\n      +'\n      +\n     -+test_expect_success 'hook is not invoked with --dry-run (show-only)' '\n     -+\ttest_when_finished \"rm -f actual\" &&\n     ++test_expect_success 'hook respects GIT_INDEX_FILE' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n     ++\t\t\t    alt-index alt-index.lock\" &&\n      +\ttest_hook pre-add <<-\\EOF &&\n     -+\techo should-not-run >>actual\n     -+\texit 1\n     ++\techo \"$1\" >arg-one &&\n     ++\techo \"$2\" >arg-two\n      +\tEOF\n     ++\techo changed >>file &&\n     ++\tGIT_INDEX_FILE=alt-index git add file &&\n     ++\techo \"$PWD/alt-index\" >expect-index &&\n     ++\ttest_cmp expect-index arg-one &&\n     ++\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n     ++\ttest_cmp expect-lockpath arg-two\n     ++'\n      +\n     -+\techo content >file &&\n     -+\tgit add --dry-run file &&\n     -+\ttest_path_is_missing actual\n     ++test_expect_success 'setup for mixed-result tests' '\n     ++\techo \"*.ignored\" >.gitignore &&\n     ++\tgit add .gitignore &&\n     ++\tgit commit -m \"add gitignore\"\n      +'\n      +\n     -+test_expect_success 'hook is invoked with git add -u' '\n     -+\ttest_when_finished \"rm -f actual expected file\" &&\n     -+\techo \"initial\" >file &&\n     -+\tgit add file &&\n     -+\tgit commit -m \"initial\" &&\n     -+\techo \"pre-add\" >expected &&\n     ++test_expect_success 'mixed-result add invokes pre-add hook' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n     ++\techo changed >>file &&\n     ++\techo ignored >bad.ignored &&\n     ++\tcp .git/index index.before &&\n      +\ttest_hook pre-add <<-\\EOF &&\n     -+\techo pre-add >>actual\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n     ++\tgrep \"^file$\" proposed &&\n     ++\techo invoked >hook-ran &&\n     ++\texit 1\n      +\tEOF\n     -+\n     -+\techo modified >file &&\n     -+\tgit add -u &&\n     -+\ttest_cmp expected actual\n     ++\ttest_must_fail git add file bad.ignored &&\n     ++\ttest_path_is_file hook-ran &&\n     ++\ttest_cmp_bin index.before .git/index &&\n     ++\ttest_path_is_missing .git/index.lock\n      +'\n      +\n     -+test_expect_success 'hook can compare original and proposed index' '\n     -+\ttest_when_finished \"rm -f old-raw new-raw old-list new-list \\\n     -+\t\t\t    expected-old expected-new\" &&\n     -+\techo \"initial\" >file1 &&\n     -+\techo \"initial\" >file2 &&\n     -+\tgit add file1 file2 &&\n     -+\tgit commit -m \"initial\" &&\n     -+\techo \"staged-before\" >file1 &&\n     -+\tgit add file1 &&\n     ++test_expect_success 'mixed-result add stages tracked update on approve' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n     ++\techo changed >>file &&\n     ++\techo ignored >bad.ignored &&\n      +\ttest_hook pre-add <<-\\EOF &&\n     -+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n     -+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n     -+\tsort old-raw >old-list &&\n     -+\tsort new-raw >new-list\n     ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n     ++\tgrep \"^file$\" proposed &&\n     ++\techo invoked >hook-ran\n      +\tEOF\n     -+\n     -+\techo \"modified\" >file2 &&\n     -+\tgit add file2 &&\n     -+\techo file1 >expected-old &&\n     -+\tprintf \"%s\\n\" file1 file2 >expected-new &&\n     -+\ttest_cmp expected-old old-list &&\n     -+\ttest_cmp expected-new new-list\n     ++\ttest_must_fail git add file bad.ignored &&\n     ++\ttest_path_is_file hook-ran &&\n     ++\tgit diff --cached --name-only HEAD >staged &&\n     ++\tgrep \"^file$\" staged &&\n     ++\ttest_path_is_missing .git/index.lock\n      +'\n      +\n     -+test_expect_success 'hook rejection rolls back index unchanged' '\n     -+\ttest_when_finished \"rm -f file before after old-raw new-raw \\\n     -+\t\t\t    old-list new-list expected-old expected-new\" &&\n     -+\techo \"initial\" >file &&\n     ++test_expect_success 'post-index-change fires after pre-add approval' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f hook-order expect lockfile-present\" &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\techo pre >>hook-order\n     ++\tEOF\n     ++\ttest_hook post-index-change <<-\\EOF &&\n     ++\tif test -f \".git/index.lock\"\n     ++\tthen\n     ++\t\techo locked >lockfile-present\n     ++\tfi\n     ++\techo post >>hook-order\n     ++\tEOF\n     ++\techo updated >>file &&\n      +\tgit add file &&\n     -+\tgit commit -m \"initial\" &&\n     -+\tgit diff --cached --name-only HEAD >before &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tpre\n     ++\tpost\n     ++\tEOF\n     ++\ttest_cmp expect hook-order &&\n     ++\ttest_path_is_missing lockfile-present\n     ++'\n     ++\n     ++test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f index.before hook-order expect\" &&\n     ++\tcp .git/index index.before &&\n      +\ttest_hook pre-add <<-\\EOF &&\n     -+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n     -+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n     -+\tsort old-raw >old-list &&\n     -+\tsort new-raw >new-list &&\n     ++\techo pre >>hook-order &&\n      +\texit 1\n      +\tEOF\n     -+\n     -+\techo \"modified\" >file &&\n     ++\ttest_hook post-index-change <<-\\EOF &&\n     ++\techo post >>hook-order\n     ++\tEOF\n     ++\techo reject >>file &&\n      +\ttest_must_fail git add file &&\n     -+\tgit diff --cached --name-only HEAD >after &&\n     -+\ttest_cmp before after &&\n     -+\t: >expected-old &&\n     -+\techo file >expected-new &&\n     -+\ttest_cmp expected-old old-list &&\n     -+\ttest_cmp expected-new new-list\n     ++\techo pre >expect &&\n     ++\ttest_cmp expect hook-order &&\n     ++\ttest_cmp_bin index.before .git/index &&\n     ++\ttest_path_is_missing .git/index.lock\n     ++'\n     ++\n     ++test_expect_success '--dry-run does not invoke hook' '\n     ++\ttest_when_finished \"rm -f hook-ran dry\" &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\techo invoked >hook-ran\n     ++\tEOF\n     ++\techo dry >dry &&\n     ++\tgit add --dry-run dry &&\n     ++\ttest_path_is_missing hook-ran\n     ++'\n     ++\n     ++test_expect_success 'hook runs for git add -u' '\n     ++\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n     ++\ttest_hook pre-add <<-\\EOF &&\n     ++\techo invoked >hook-ran\n     ++\tEOF\n     ++\techo changed >>file &&\n     ++\tgit add -u &&\n     ++\ttest_path_is_file hook-ran\n      +'\n      +\n      +test_expect_success 'hook example: block .env files' '\n     -+\ttest_when_finished \"rm -f .env safe.txt new-paths\" &&\n     -+\techo \"initial\" >base &&\n     -+\tgit add base &&\n     -+\tgit commit -m \"initial\" &&\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -f .env safe.txt new-paths\" &&\n      +\ttest_hook pre-add <<-\\EOF &&\n      +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n      +\twhile read path\n      +\tdo\n      +\t\tcase \"$path\" in\n     -+\t\t*.env|.env)\n     ++\t\t*.env)\n      +\t\t\techo \"error: $path must not be staged\" >&2\n      +\t\t\texit 1\n      +\t\t\t;;\n      +\t\tesac\n      +\tdone <new-paths\n      +\tEOF\n     -+\n      +\techo \"DB_PASS=secret\" >.env &&\n      +\ttest_must_fail git add .env &&\n      +\techo \"safe content\" >safe.txt &&\n     @@ t/t3706-pre-add-hook.sh (new)\n      +'\n      +\n      +test_expect_success 'hook example: block secrets in content' '\n     -+\ttest_when_finished \"rm -f config.txt secret\" &&\n     -+\techo \"initial\" >config.txt &&\n     -+\tgit add config.txt &&\n     -+\tgit commit -m \"initial\" &&\n     ++\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n      +\ttest_hook pre-add <<-\\EOF &&\n      +\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n     -+\tif grep -qE \"(API_KEY|SECRET_KEY|PRIVATE_KEY)=\" secret\n     ++\tif grep -q \"API_KEY=\" secret ||\n     ++\t   grep -q \"SECRET_KEY=\" secret ||\n     ++\t   grep -q \"PRIVATE_KEY=\" secret\n      +\tthen\n      +\t\techo \"error: staged content contains secrets\" >&2\n      +\t\texit 1\n      +\tfi\n      +\tEOF\n     -+\n      +\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n      +\ttest_must_fail git add config.txt &&\n      +\techo \"LOG_LEVEL=debug\" >config.txt &&\n\n\n Documentation/git-add.adoc  |  11 +-\n Documentation/githooks.adoc |  30 ++++\n builtin/add.c               |  47 +++++-\n read-cache-ll.h             |   1 +\n read-cache.c                |  13 +-\n t/meson.build               |   1 +\n t/t3706-pre-add-hook.sh     | 289 ++++++++++++++++++++++++++++++++++++\n 7 files changed, 381 insertions(+), 11 deletions(-)\n create mode 100755 t/t3706-pre-add-hook.sh\n\ndiff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\nindex 6192daeb03..b47751acca 100644\n--- a/Documentation/git-add.adoc\n+++ b/Documentation/git-add.adoc\n@@ -10,7 +10,7 @@ SYNOPSIS\n [synopsis]\n git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n-\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n+\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n \t[--] [<pathspec>...]\n \n@@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n filename of an ignored file, `git add` will fail with a list of ignored\n files. Otherwise it will silently ignore the file.\n \n+A `pre-add` hook can be run to inspect or reject the proposed index update\n+after `git add` computes staging and writes it to the index lockfile,\n+but before writing it to the final index. See linkgit:githooks[5].\n+\n Please see linkgit:git-commit[1] for alternative ways to add content to a\n commit.\n \n@@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n \tDon't add the file(s), but only refresh their stat()\n \tinformation in the index.\n \n+`--no-verify`::\n+\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n+\tmore information about hooks.\n+\n `--ignore-errors`::\n \tIf some files could not be added because of errors indexing\n \tthem, do not abort the operation, but continue adding the\n@@ -451,6 +459,7 @@ linkgit:git-reset[1]\n linkgit:git-mv[1]\n linkgit:git-commit[1]\n linkgit:git-update-index[1]\n+linkgit:githooks[5]\n \n GIT\n ---\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 056553788d..657e14d306 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n This hook is meant primarily for notification, and cannot affect\n the outcome of `git am`.\n \n+pre-add\n+~~~~~~~\n+\n+This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n+`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n+`--edit`, or `--dry-run`.\n+\n+It takes two parameters: the path to the index file for this invocation\n+of `git add`, and the path to the lockfile containing the proposed\n+index after staging. It does not read from standard input. If no index\n+exists yet, the first parameter names a path that does not exist and\n+should be treated as an empty index.\n+\n+The hook is invoked after the index has been updated in memory and\n+written to the lockfile, but before it is committed to the final index\n+path. Exiting with a non-zero status causes `git add` to reject the\n+proposed state, roll back the lockfile, and leave the index unchanged.\n+Exiting with zero status allows the index update to be committed.\n+\n+Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n+set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n+`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n+\n+This hook can be used to prevent staging of files based on names, content,\n+or sizes (e.g., to block `.env` files, secret keys, or large files).\n+\n+This hook is not invoked by `git commit -a` or `git commit --include`\n+which still can run the `pre-commit` hook, providing a control point at\n+commit time.\n+\n pre-commit\n ~~~~~~~~~~\n \ndiff --git a/builtin/add.c b/builtin/add.c\nindex 32709794b3..d4d004a35b 100644\n--- a/builtin/add.c\n+++ b/builtin/add.c\n@@ -25,6 +25,8 @@\n #include \"strvec.h\"\n #include \"submodule.h\"\n #include \"add-interactive.h\"\n+#include \"hook.h\"\n+#include \"abspath.h\"\n \n static const char * const builtin_add_usage[] = {\n \tN_(\"git add [<options>] [--] <pathspec>...\"),\n@@ -36,6 +38,7 @@ static int take_worktree_changes;\n static int add_renormalize;\n static int pathspec_file_nul;\n static int include_sparse;\n+static int no_verify;\n static const char *pathspec_from_file;\n \n static int chmod_pathspec(struct repository *repo,\n@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {\n \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n+\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n \t\t   N_(\"override the executable bit of the listed files\")),\n@@ -391,6 +395,8 @@ int cmd_add(int argc,\n \tchar *ps_matched = NULL;\n \tstruct lock_file lock_file = LOCK_INIT;\n \tstruct odb_transaction *transaction;\n+\tint run_pre_add = 0;\n+\tchar *orig_index_path = NULL;\n \n \trepo_config(repo, add_config, NULL);\n \n@@ -576,6 +582,11 @@ int cmd_add(int argc,\n \t\tstring_list_clear(&only_match_skip_worktree, 0);\n \t}\n \n+\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n+\t\trun_pre_add = 1;\n+\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n+\t}\n+\n \ttransaction = odb_transaction_begin(repo->objects);\n \n \tps_matched = xcalloc(pathspec.nr, 1);\n@@ -587,8 +598,10 @@ int cmd_add(int argc,\n \t\t\t\t\t\t  include_sparse, flags);\n \n \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n-\t    report_path_error(ps_matched, &pathspec))\n+\t    report_path_error(ps_matched, &pathspec)) {\n+\t\tfree(orig_index_path);\n \t\texit(128);\n+\t}\n \n \tif (add_new_files)\n \t\texit_status |= add_files(repo, &dir, flags);\n@@ -598,9 +611,35 @@ int cmd_add(int argc,\n \todb_transaction_commit(transaction);\n \n finish:\n-\tif (write_locked_index(repo->index, &lock_file,\n-\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n-\t\tdie(_(\"unable to write new index file\"));\n+\tif (run_pre_add && repo->index->cache_changed) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n+\t\t\tdie(_(\"unable to write proposed index\"));\n+\n+\t\tstrvec_push(&opt.args, orig_index_path);\n+\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n+\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n+\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n+\t\t\texit_status = 1;\n+\t\t} else if (commit_lock_file(&lock_file)) {\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t\t} else {\n+\t\t\trun_hooks_l(repo, \"post-index-change\",\n+\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n+\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n+\t\t\t\t    NULL);\n+\t\t}\n+\t\trepo->index->updated_workdir = 0;\n+\t\trepo->index->updated_skipworktree = 0;\n+\t} else {\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t}\n+\n+\tfree(orig_index_path);\n \n \tfree(ps_matched);\n \tdir_clear(&dir);\ndiff --git a/read-cache-ll.h b/read-cache-ll.h\nindex 71b49d9af4..a43971c07e 100644\n--- a/read-cache-ll.h\n+++ b/read-cache-ll.h\n@@ -284,6 +284,7 @@ int is_index_unborn(struct index_state *);\n /* For use with `write_locked_index()`. */\n #define COMMIT_LOCK\t\t(1 << 0)\n #define SKIP_IF_UNCHANGED\t(1 << 1)\n+#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n \n /*\n  * Write the index while holding an already-taken lock. Close the lock,\ndiff --git a/read-cache.c b/read-cache.c\nindex 0c07c3aef7..5051cda4ce 100644\n--- a/read-cache.c\n+++ b/read-cache.c\n@@ -3161,12 +3161,13 @@ static int do_write_locked_index(struct index_state *istate,\n \telse\n \t\tret = close_lock_file_gently(lock);\n \n-\trun_hooks_l(the_repository, \"post-index-change\",\n-\t\t    istate->updated_workdir ? \"1\" : \"0\",\n-\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n-\tistate->updated_workdir = 0;\n-\tistate->updated_skipworktree = 0;\n-\n+\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n+\t\trun_hooks_l(the_repository, \"post-index-change\",\n+\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n+\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n+\t\tistate->updated_workdir = 0;\n+\t\tistate->updated_skipworktree = 0;\n+\t}\n \treturn ret;\n }\n \ndiff --git a/t/meson.build b/t/meson.build\nindex f80e366cff..2419a9adbb 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -415,6 +415,7 @@ integration_tests = [\n   't3703-add-magic-pathspec.sh',\n   't3704-add-pathspec-file.sh',\n   't3705-add-sparse-checkout.sh',\n+  't3706-pre-add-hook.sh',\n   't3800-mktag.sh',\n   't3900-i18n-commit.sh',\n   't3901-i18n-patch.sh',\ndiff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\nnew file mode 100755\nindex 0000000000..f5092f0727\n--- /dev/null\n+++ b/t/t3706-pre-add-hook.sh\n@@ -0,0 +1,289 @@\n+#!/bin/sh\n+\n+test_description='pre-add hook tests\n+\n+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).'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'with no hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success POSIXPERM 'with non-executable hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\tchmod -x .git/hooks/pre-add &&\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success '--no-verify with no hook' '\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'with succeeding hook' '\n+\ttest_when_finished \"rm -f actual expected\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add-rejected >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\ttest_must_fail git add file\n+'\n+\n+test_expect_success '--no-verify with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'setup for path-based tests' '\n+\tgit add file &&\n+\tgit commit -m \"initial\"\n+'\n+\n+test_expect_success 'hook receives index-path and lockfile-path arguments' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n+\t\t\t    expect-index expect-lockpath\" &&\n+\techo staged >staged &&\n+\tcat >expect-count <<-\\EOF &&\n+\t2\n+\tEOF\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$#\" >arg-count &&\n+\techo \"$1\" >arg-one &&\n+\techo \"$2\" >arg-two &&\n+\ttest \"$1\" != \"$2\" &&\n+\ttest -r \"$2\"\n+\tEOF\n+\tgit add staged &&\n+\ttest_cmp expect-count arg-count &&\n+\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n+\ttest_cmp expect-lockpath arg-two\n+'\n+\n+test_expect_success 'hook rejection leaves final index unchanged' '\n+\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\techo reject >reject &&\n+\ttest_must_fail git add reject &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'missing pre-existing index path treated as empty' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n+\trm -f .git/index &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$1\" >arg-one &&\n+\ttest ! -e \"$1\" &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n+\tsort after.raw >after\n+\tEOF\n+\techo newfile >newfile &&\n+\tgit add newfile &&\n+\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\tgrep \"^newfile$\" after &&\n+\tgrep \"^file$\" after\n+'\n+\n+test_expect_success 'hook respects GIT_INDEX_FILE' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n+\t\t\t    alt-index alt-index.lock\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$1\" >arg-one &&\n+\techo \"$2\" >arg-two\n+\tEOF\n+\techo changed >>file &&\n+\tGIT_INDEX_FILE=alt-index git add file &&\n+\techo \"$PWD/alt-index\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n+\ttest_cmp expect-lockpath arg-two\n+'\n+\n+test_expect_success 'setup for mixed-result tests' '\n+\techo \"*.ignored\" >.gitignore &&\n+\tgit add .gitignore &&\n+\tgit commit -m \"add gitignore\"\n+'\n+\n+test_expect_success 'mixed-result add invokes pre-add hook' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n+\techo changed >>file &&\n+\techo ignored >bad.ignored &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n+\tgrep \"^file$\" proposed &&\n+\techo invoked >hook-ran &&\n+\texit 1\n+\tEOF\n+\ttest_must_fail git add file bad.ignored &&\n+\ttest_path_is_file hook-ran &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'mixed-result add stages tracked update on approve' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n+\techo changed >>file &&\n+\techo ignored >bad.ignored &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n+\tgrep \"^file$\" proposed &&\n+\techo invoked >hook-ran\n+\tEOF\n+\ttest_must_fail git add file bad.ignored &&\n+\ttest_path_is_file hook-ran &&\n+\tgit diff --cached --name-only HEAD >staged &&\n+\tgrep \"^file$\" staged &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'post-index-change fires after pre-add approval' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f hook-order expect lockfile-present\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre >>hook-order\n+\tEOF\n+\ttest_hook post-index-change <<-\\EOF &&\n+\tif test -f \".git/index.lock\"\n+\tthen\n+\t\techo locked >lockfile-present\n+\tfi\n+\techo post >>hook-order\n+\tEOF\n+\techo updated >>file &&\n+\tgit add file &&\n+\tcat >expect <<-\\EOF &&\n+\tpre\n+\tpost\n+\tEOF\n+\ttest_cmp expect hook-order &&\n+\ttest_path_is_missing lockfile-present\n+'\n+\n+test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f index.before hook-order expect\" &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre >>hook-order &&\n+\texit 1\n+\tEOF\n+\ttest_hook post-index-change <<-\\EOF &&\n+\techo post >>hook-order\n+\tEOF\n+\techo reject >>file &&\n+\ttest_must_fail git add file &&\n+\techo pre >expect &&\n+\ttest_cmp expect hook-order &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success '--dry-run does not invoke hook' '\n+\ttest_when_finished \"rm -f hook-ran dry\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\techo dry >dry &&\n+\tgit add --dry-run dry &&\n+\ttest_path_is_missing hook-ran\n+'\n+\n+test_expect_success 'hook runs for git add -u' '\n+\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\techo changed >>file &&\n+\tgit add -u &&\n+\ttest_path_is_file hook-ran\n+'\n+\n+test_expect_success 'hook example: block .env files' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f .env safe.txt new-paths\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n+\twhile read path\n+\tdo\n+\t\tcase \"$path\" in\n+\t\t*.env)\n+\t\t\techo \"error: $path must not be staged\" >&2\n+\t\t\texit 1\n+\t\t\t;;\n+\t\tesac\n+\tdone <new-paths\n+\tEOF\n+\techo \"DB_PASS=secret\" >.env &&\n+\ttest_must_fail git add .env &&\n+\techo \"safe content\" >safe.txt &&\n+\tgit add safe.txt\n+'\n+\n+test_expect_success 'hook example: block secrets in content' '\n+\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n+\tif grep -q \"API_KEY=\" secret ||\n+\t   grep -q \"SECRET_KEY=\" secret ||\n+\t   grep -q \"PRIVATE_KEY=\" secret\n+\tthen\n+\t\techo \"error: staged content contains secrets\" >&2\n+\t\texit 1\n+\tfi\n+\tEOF\n+\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n+\ttest_must_fail git add config.txt &&\n+\techo \"LOG_LEVEL=debug\" >config.txt &&\n+\tgit add config.txt\n+'\n+\n+test_done\n\nbase-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4\n-- \ngitgitgadget\n"},{"id":"537738","messageId":"xmqqy0k8a4xo.fsf@gitster.g","threadId":"64972","inReplyTo":"pull.2045.v3.git.1772171692465.gitgitgadget@gmail.com","subject":"Re: [PATCH v3] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-03T23:06:43Z","receivedAt":"2026-03-03T23:06:46Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> \"git add\" has no hook that lets users inspect what is about to be\n> staged. Users who want to reject certain paths or content must\n> wrap the command in a shell alias or wait for pre-commit, which\n> fires too late to prevent staging.\n\nI do not think the above would convince readers that \"preventing to\nadd\" is a worthy goal in the first place.  If you \"git add foo\" by\nmistake and wish you had this hook to prevent 'foo' from getting\nadded ever, you can easily \"git reset foo\" to undo it.\n\n> Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n> new index state but before committing it to disk. The hook\n> receives two positional arguments:\n>\n>   $1 -- index path used by this invocation (may not exist yet)\n>   $2 -- lockfile path containing proposed staged index state\n\nOK, perhaps.\n\n> While the lockfile is active the current index path remains readable\n> and unchanged, so a seperate copy is unnecessary. \n\nUnless readers may think that it is needed to make a separate copy\nin order to prevent \"git add\" from happening, and I am not sure why\nwe would expect readers to do so, this is not something we need to\nsay here, is it, even thought it might not be telling any lies?\n\nWhat is more important would be to tell readers that these two index\nfiles are meant to be read-only and hooks are not expected to modify\nthem.\n\n> Hook authors can\n> inspect the computed result with ordinary tools:\n>\n>   GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n>\n> without needing to interpret pathspec or mode flags as the proposed\n> index already reflects their effect.\n\nGood.\n\n> At the finish label, write_locked_index() writes the proposed index\n> to the lockfile without COMMIT_LOCK so commit_lock_file() can be\n> called seperately after the hook runs. However, do_write_locked_index()\n> unconditionally fires post-index-change after every write, and ...\n\nAre these implementation details really needed to be described here\nfor future developers to understand what this change was while they\nread the \"git log -p\" output and find this commit?\n\n> the\n> existing test suite (t7113) asserts that index.lock does not exist when\n> that hook fires. Tying the hook to COMMIT_LOCK would suppress it for\n> other callers that depend on it after a non-committed write (e.g.,\n> prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK\n> flag lets builtin/add.c suppress the automatic notification on just this\n> call, then emit post-index-change manually after commit_lock_file()\n> publishes the new index. If the hook rejects, rollback_lock_file()\n> discards the lockfile and the original index is left unchanged. When\n> no hook is installed the existing write_locked_index(COMMIT_LOCK |\n> SKIP_IF_UNCHANGED) path is taken.\n\nIOW, what does it help the reader to read the above wall of text?\n\n> The hook gate checks cache_changed regardless of exit_status so that\n> mixed-result adds (e.g., a tracked modification combined with an\n> ignored path) still run the hook when index content changes.\n>\n> The hook is bypassed with \"--no-verify\" and is not invoked for\n> --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n> which stages through its own code path.\n>\n> Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n> ---\n>     \n>  Documentation/git-add.adoc  |  11 +-\n>  Documentation/githooks.adoc |  30 ++++\n>  builtin/add.c               |  47 +++++-\n>  read-cache-ll.h             |   1 +\n>  read-cache.c                |  13 +-\n>  t/meson.build               |   1 +\n>  t/t3706-pre-add-hook.sh     | 289 ++++++++++++++++++++++++++++++++++++\n>  7 files changed, 381 insertions(+), 11 deletions(-)\n>  create mode 100755 t/t3706-pre-add-hook.sh\n>\n> diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\n> index 6192daeb03..b47751acca 100644\n> --- a/Documentation/git-add.adoc\n> +++ b/Documentation/git-add.adoc\n> @@ -10,7 +10,7 @@ SYNOPSIS\n>  [synopsis]\n>  git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n>  \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n> -\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n> +\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n\nAvoid making the line that is already overly long even worse.\n\n> @@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n>  filename of an ignored file, `git add` will fail with a list of ignored\n>  files. Otherwise it will silently ignore the file.\n>  \n> +A `pre-add` hook can be run to inspect or reject the proposed index update\n> +after `git add` computes staging and writes it to the index lockfile,\n> +but before writing it to the final index. See linkgit:githooks[5].\n\nI think the above (as with everything else you wrote in the patch,\nincluding a part of the proposed commit log message) stresses too\nmuch more on the implementation detail than what would help your\nintended readers.  How about writing it more like this?\n\n    The `pre-add` hook, if exists, is run with a temporary index\n    file that shows the result of proposed `git add` to inspect.  By\n    exiting with non-zero status, the hook can reject the proposed\n    changes.  If the hook exits with zero status, this temporary\n    index file will become the final result.\n\nThe readers do not have to know 'lockfile' or 'final index'.  They\nwould want to know how to accept or reject the proposed result.\n\nOr we can leave all the details to linkgit:githooks[5] and say\nonly something like this\n\n    A `pre-add` hook can be used to reject `git add`; see\n    linkgit:githooks[5].\n\nand nothing else.\n\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index 056553788d..657e14d306 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n>  This hook is meant primarily for notification, and cannot affect\n>  the outcome of `git am`.\n>  \n> +pre-add\n> +~~~~~~~\n> +\n> +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n> +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> +`--edit`, or `--dry-run`.\n> +\n> +It takes two parameters: the path to the index file for this invocation\n\nElsewhere you called these two files \"arguments\" but here you say\n\"parameters\".  Let's be consistent.\n\n> +of `git add`, and the path to the lockfile containing the proposed\n> +index after staging. It does not read from standard input. If no index\n> +exists yet, the first parameter names a path that does not exist and\n> +should be treated as an empty index.\n> +\n> +The hook is invoked after the index has been updated in memory and\n> +written to the lockfile, but before it is committed to the final index\n> +path. Exiting with a non-zero status causes `git add` to reject the\n> +proposed state, roll back the lockfile, and leave the index unchanged.\n> +Exiting with zero status allows the index update to be committed.\n\nGood write-up.\n\n> +Git does not set `GIT_INDEX_FILE` for this hook. \n\nI am not sure what the point of mentioning GIT_INDEX_FILE here.  If\nthe user did\n\n    $ GIT_INDEX_FILE=.git/alt-index git add files...\n\nthe \"git\" process has the environment variable in place, pointing at\nthe file as _the_ index file to add to.  We do not unset and\nunexport the environment variable before invoking the hook, do we?\nWe simply do not do anything special or strange.  It makes it less\nconfusing if we refrain from saying \"we do not do this unusual thing\nor that special thing\", doesn't it?\n\n> Hook authors may\n> +set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n> +`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n\nExplaining this one does make sense.  \"current\" -> \"the current\"\nand \"proposed\" -> \"the proposed\", I think.\n\nSomewhere around here, it would be necessary to say that these two\nfiles should be treated as read-only by this hook.\n\n> +\tint run_pre_add = 0;\n> +\tchar *orig_index_path = NULL;\n>  \n>  \trepo_config(repo, add_config, NULL);\n>  \n> @@ -576,6 +582,11 @@ int cmd_add(int argc,\n>  \t\tstring_list_clear(&only_match_skip_worktree, 0);\n>  \t}\n>  \n> +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> +\t\trun_pre_add = 1;\n> +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n> +\t}\n> +\n>  \ttransaction = odb_transaction_begin(repo->objects);\n>  \n>  \tps_matched = xcalloc(pathspec.nr, 1);\n> @@ -587,8 +598,10 @@ int cmd_add(int argc,\n>  \t\t\t\t\t\t  include_sparse, flags);\n>  \n>  \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n> -\t    report_path_error(ps_matched, &pathspec))\n> +\t    report_path_error(ps_matched, &pathspec)) {\n> +\t\tfree(orig_index_path);\n>  \t\texit(128);\n> +\t}\n\nHmph, we are not releasing ps_matched or transaction and nothing is\nleaking (the on-stack variables do hold references to these\nresources).  I do not see much point in releasing orig_index_path\nhere.\n\n> @@ -598,9 +611,35 @@ int cmd_add(int argc,\n>  \todb_transaction_commit(transaction);\n>  \n>  finish:\n> -\tif (write_locked_index(repo->index, &lock_file,\n> -\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> -\t\tdie(_(\"unable to write new index file\"));\n> +\tif (run_pre_add && repo->index->cache_changed) {\n> +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> +\n> +\t\tif (write_locked_index(repo->index, &lock_file,\n> +\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n> +\t\t\tdie(_(\"unable to write proposed index\"));\n\nAs we _may_ allow the pre-add hook to reject it, we do not know if\nthe index has changed.  So delaying the post-index-change hook until\nwe know for sure that we will commit to the index change does make\nperfect sense.\n\n> +\t\tstrvec_push(&opt.args, orig_index_path);\n> +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n> +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n> +\t\t\texit_status = 1;\n\nAnd then we ask the new hook, which may reject the update, in which\ncase we leave here.  Otherwise ...\n\n> +\t\t} else if (commit_lock_file(&lock_file)) {\n> +\t\t\tdie(_(\"unable to write new index file\"));\n> +\t\t} else {\n\n... we commit the index file to the final place and then invoke the\npost-index-change hook ourselves, as we told write_locked_index()\nnot to do that earlier.  Makes sense.\n\n> +\t\t\trun_hooks_l(repo, \"post-index-change\",\n> +\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n> +\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n> +\t\t\t\t    NULL);\n> +\t\t}\n> +\t\trepo->index->updated_workdir = 0;\n> +\t\trepo->index->updated_skipworktree = 0;\n\nDoesn't these two belong to the \"run post-index-change hook\" block?\nI think all the contents in the final \"else {}\" block that you\ncopied from do_write_locked_index() should be refactored into a\nsmall helper function and called from here and also from\ndo_write_locked_index().  Otherwise, you'll be forced to maintain\nthe details of what needs to happen when running \"post-index-change\"\nat multiple places and they must be kept in sync.\n\n> +\t} else {\n> +\t\tif (write_locked_index(repo->index, &lock_file,\n> +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> +\t\t\tdie(_(\"unable to write new index file\"));\n> +\t}\n> +\n> +\tfree(orig_index_path);\n>  \n>  \tfree(ps_matched);\n>  \tdir_clear(&dir);\n"},{"id":"537767","messageId":"33EBA399-2D24-48C7-AA1B-EBADF5E520D4@gmail.com","threadId":"64972","inReplyTo":"xmqqy0k8a4xo.fsf@gitster.g","subject":"Re: [PATCH v3] add: support pre-add hook","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-03-04T09:49:47Z","receivedAt":"2026-03-04T09:50:00Z","isPatch":true,"sender":{"key":"ben.knoble@gmail.com","avatar":"https://avatars.githubusercontent.com/u/22802209?v=4"},"body":"\n> Le 3 mars 2026 à 18:06, Junio C Hamano <gitster@pobox.com> a écrit :\n> \n> ﻿\"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com>\n> writes:\n> \n>> \"git add\" has no hook that lets users inspect what is about to be\n>> staged. Users who want to reject certain paths or content must\n>> wrap the command in a shell alias or wait for pre-commit, which\n>> fires too late to prevent staging.\n> \n> I do not think the above would convince readers that \"preventing to\n> add\" is a worthy goal in the first place.  If you \"git add foo\" by\n> mistake and wish you had this hook to prevent 'foo' from getting\n> added ever, you can easily \"git reset foo\" to undo it.\n\nIt’s also not clear to me how the proposed hook could inspect “git add A B” and reject A but permit B, but maybe that’s a non-goals. \n\n>> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n>> index 056553788d..657e14d306 100644\n>> --- a/Documentation/githooks.adoc\n>> +++ b/Documentation/githooks.adoc\n>> @@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n>> This hook is meant primarily for notification, and cannot affect\n>> the outcome of `git am`.\n>> \n>> +pre-add\n>> +~~~~~~~\n>> +\n>> +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n>> +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n>> +`--edit`, or `--dry-run`.\n>> +\n>> +It takes two parameters: the path to the index file for this invocation\n> \n> Elsewhere you called these two files \"arguments\" but here you say\n> \"parameters\".  Let's be consistent.\n> \n>> +of `git add`, and the path to the lockfile containing the proposed\n>> +index after staging. It does not read from standard input. If no index\n>> +exists yet, the first parameter names a path that does not exist and\n>> +should be treated as an empty index.\n\nSaying “it [the hook] does not read from standard in” feels proscriptive rather than descriptive. Why couldn’t I write a short script that asked for confirmation of the paths being added via stdin?\n\nOr perhaps we mean that Git does not write anything to the hook’s stdin… at which point I wonder if Junio’s “let’s not mention that we don’t do this unusual thing” applies? I haven’t looked at how the rest of our documentation describes hooks that aren’t fed input via stdin. "},{"id":"537915","messageId":"27ee9a9c-0caa-4b6e-a968-51c71c8b6e5f@gmail.com","threadId":"64972","inReplyTo":"pull.2045.v3.git.1772171692465.gitgitgadget@gmail.com","subject":"Re: [PATCH v3] add: support pre-add hook","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-03-05T10:47:27Z","receivedAt":"2026-03-05T10:47:36Z","isPatch":true,"sender":{"key":"phillip.wood@dunelm.org.uk","avatar":null},"body":"On 27/02/2026 05:54, Chandra Kethi-Reddy via GitGitGadget wrote:\n> From: Chandra Kethi-Reddy <chandrakr@pm.me>\n> \n> \"git add\" has no hook that lets users inspect what is about to be\n> staged. Users who want to reject certain paths or content must\n> wrap the command in a shell alias or wait for pre-commit, which\n> fires too late to prevent staging.\n> \n> Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n> new index state but before committing it to disk. The hook\n> receives two positional arguments:\n> \n>    $1 -- index path used by this invocation (may not exist yet)\n\nDoes this mean 'the index state before running \"git add\"'?\n\n>    $2 -- lockfile path containing proposed staged index state\n> \n> While the lockfile is active the current index path remains readable\n> and unchanged, so a seperate copy is unnecessary. Hook authors can\n> inspect the computed result with ordinary tools:\n> \n>    GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n\nIf I understand the definition of \"$2\" above correctly this will show \nall the paths that have been staged since the last commit, not just the \npaths that are staged by the current invocation of \"git add\". That means \nif for some reason I need to bypass the hook when running \"git add\" I'll \nhave to bypass it every time until I commit and cannot check the other \nchanges that I'm staging. It also means that running \"git add\" several \ntimes, each with a different path runs the hook multiple times on the \nsame content.\n\nTo get the list of paths that have changed since the last invocation of \n\"git add\" you'd need to diff against the other index which isn't \npossible to do directly. If there are no unmerged paths you can write a \ntree but if there are unmerged paths \"git write-tree\" will fail and so \nyou cannot use things like \"git diff --check\" and have to fall back to \ninspecting the changes by running \"git diff-index --cached\" on each \nindex, munging them together and feeding that into \"git diff-pairs\"\n\n> \n> without needing to interpret pathspec or mode flags as the proposed\n> index already reflects their effect.\n> \n> The hook is bypassed with \"--no-verify\" and is not invoked for\n> --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n> which stages through its own code path.\n\nThese caveats are rather unfortunate as it means to be sure that staged \nchanges get checked I have to duplicate the \"pre-add\" checks in the \n\"pre-commit\" hook which is rather inefficient. It would be very nice to \nbe able to check changes as they're staged rather than just before they \nare committed but I can't help feeling that what's proposed here is \ndriven by ease of implementation which leads to a rather incoherent user \nexperience.\n\nThanks\n\nPhillip\n\n> \n> Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n> ---\n>      add: support pre-add hook\n>      \n>      \n>      Summary\n>      =======\n>      \n>       * v3 switches pre-add inputs to stable paths ($1 index, $2 lockfile)\n>         and removes copy-specific tempfile logic\n>       * v3 fixes mixed-result gating so the hook runs whenever index content\n>         changed, even if git add returned non-zero\n>       * v3 adds SKIP_INDEX_CHANGE_HOOK flag to write_locked_index() so that\n>         post-index-change is not fired while the lockfile is still on disk\n>      \n>      \n>      Notes\n>      =====\n>      \n>       * This design intentionally trades ODB prevention for correctness of\n>         hook inputs: blobs may already be written to object storage when the\n>         hook runs, but hook rejection still leaves the on-disk index\n>         unchanged\n>       * AI Disclosure: Codex and Claude Code CLI were used to assist\n>         drafting. All tests, code, and docs were committed by hand.\n> \n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v3\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v3\n> Pull-Request: https://github.com/gitgitgadget/git/pull/2045\n> \n> Range-diff vs v2:\n> \n>   1:  10244150e24 ! 1:  d0fb5f9da21 add: support pre-add hook\n>       @@ Commit message\n>            \"git add\" has no hook that lets users inspect what is about to be\n>            staged. Users who want to reject certain paths or content must\n>            wrap the command in a shell alias or wait for pre-commit, which\n>       -    fires after staging is already done and objects may already be in\n>       -    the object database.\n>       +    fires too late to prevent staging.\n>        \n>            Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n>            new index state but before committing it to disk. The hook\n>       -    receives two arguments:\n>       +    receives two positional arguments:\n>        \n>       -      $1 -- path to a temporary copy of the index before this \"git add\"\n>       -      $2 -- path to the lockfile containing the proposed index\n>       +      $1 -- index path used by this invocation (may not exist yet)\n>       +      $2 -- lockfile path containing proposed staged index state\n>        \n>       -    $1 on first add can be a non-existent path representing an empty\n>       -    index.\n>       -\n>       -    Hook authors can inspect the computed result with ordinary tools:\n>       +    While the lockfile is active the current index path remains readable\n>       +    and unchanged, so a seperate copy is unnecessary. Hook authors can\n>       +    inspect the computed result with ordinary tools:\n>        \n>              GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n>        \n>       -    without needing to interpret pathspec or mode flags like \"-u\" or\n>       -    \"--renormalize\" -- the proposed index already reflects their effect.\n>       +    without needing to interpret pathspec or mode flags as the proposed\n>       +    index already reflects their effect.\n>       +\n>       +    At the finish label, write_locked_index() writes the proposed index\n>       +    to the lockfile without COMMIT_LOCK so commit_lock_file() can be\n>       +    called seperately after the hook runs. However, do_write_locked_index()\n>       +    unconditionally fires post-index-change after every write, and the\n>       +    existing test suite (t7113) asserts that index.lock does not exist when\n>       +    that hook fires. Tying the hook to COMMIT_LOCK would suppress it for\n>       +    other callers that depend on it after a non-committed write (e.g.,\n>       +    prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK\n>       +    flag lets builtin/add.c suppress the automatic notification on just this\n>       +    call, then emit post-index-change manually after commit_lock_file()\n>       +    publishes the new index. If the hook rejects, rollback_lock_file()\n>       +    discards the lockfile and the original index is left unchanged. When\n>       +    no hook is installed the existing write_locked_index(COMMIT_LOCK |\n>       +    SKIP_IF_UNCHANGED) path is taken.\n>        \n>       -    The implementation creates a temporary copy of the index via the\n>       -    tempfile API when find_hook(\"pre-add\") reports a hook is present,\n>       -    then lets all staging proceed normally. At the finish label,\n>       -    write_locked_index() writes the proposed index to the lockfile\n>       -    without COMMIT_LOCK. If the hook approves, commit_lock_file()\n>       -    atomically replaces the index. If the hook rejects,\n>       -    rollback_lock_file() discards the lockfile and the original index\n>       -    is left unchanged. When no hook is installed, the existing\n>       -    write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still\n>       -    taken.\n>       +    The hook gate checks cache_changed regardless of exit_status so that\n>       +    mixed-result adds (e.g., a tracked modification combined with an\n>       +    ignored path) still run the hook when index content changes.\n>        \n>            The hook is bypassed with \"--no-verify\" and is not invoked for\n>            --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n>            which stages through its own code path.\n>        \n>       -    Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson\n>       -    and Makefile lists.\n>       -\n>            Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n>        \n>         ## Documentation/git-add.adoc ##\n>       @@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If yo\n>         filename of an ignored file, `git add` will fail with a list of ignored\n>         files. Otherwise it will silently ignore the file.\n>         \n>       -+A pre-add hook can be run to inspect or reject the proposed index update\n>       ++A `pre-add` hook can be run to inspect or reject the proposed index update\n>        +after `git add` computes staging and writes it to the index lockfile,\n>        +but before writing it to the final index. See linkgit:githooks[5].\n>        +\n>       @@ Documentation/git-add.adoc: for `git add --no-all <pathspec>...`, i.e. ignored r\n>         \tinformation in the index.\n>         \n>        +`--no-verify`::\n>       -+\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n>       ++\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n>        +\tmore information about hooks.\n>        +\n>         `--ignore-errors`::\n>       @@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com\n>        +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n>        +`--edit`, or `--dry-run`.\n>        +\n>       -+It takes two parameters: the path to a copy of the index before this\n>       -+invocation of `git add`, and the path to the lockfile containing the\n>       -+proposed index after staging. It does not read from standard input.\n>       -+If no index exists yet, the first parameter names a path that does not\n>       -+exist and should be treated as an empty index. No special environment\n>       -+variables are set. The hook is invoked after the index has been updated\n>       -+in memory and written to the lockfile, but before it is committed to the\n>       -+final location.\n>       ++It takes two parameters: the path to the index file for this invocation\n>       ++of `git add`, and the path to the lockfile containing the proposed\n>       ++index after staging. It does not read from standard input. If no index\n>       ++exists yet, the first parameter names a path that does not exist and\n>       ++should be treated as an empty index.\n>       ++\n>       ++The hook is invoked after the index has been updated in memory and\n>       ++written to the lockfile, but before it is committed to the final index\n>       ++path. Exiting with a non-zero status causes `git add` to reject the\n>       ++proposed state, roll back the lockfile, and leave the index unchanged.\n>       ++Exiting with zero status allows the index update to be committed.\n>        +\n>       -+Exiting with a non-zero status causes `git add` to abort and leaves the\n>       -+index unchanged. Exiting with zero status causes the staged changes to\n>       -+take effect.\n>       ++Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n>       ++set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n>       ++`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n>        +\n>        +This hook can be used to prevent staging of files based on names, content,\n>        +or sizes (e.g., to block `.env` files, secret keys, or large files).\n>        +\n>        +This hook is not invoked by `git commit -a` or `git commit --include`\n>       -+which still can run the pre-commit hook, providing a control point at\n>       ++which still can run the `pre-commit` hook, providing a control point at\n>        +commit time.\n>        +\n>         pre-commit\n>       @@ builtin/add.c\n>         #include \"submodule.h\"\n>         #include \"add-interactive.h\"\n>        +#include \"hook.h\"\n>       -+#include \"copy.h\"\n>       ++#include \"abspath.h\"\n>         \n>         static const char * const builtin_add_usage[] = {\n>         \tN_(\"git add [<options>] [--] <pathspec>...\"),\n>       @@ builtin/add.c: int cmd_add(int argc,\n>         \tstruct lock_file lock_file = LOCK_INIT;\n>         \tstruct odb_transaction *transaction;\n>        +\tint run_pre_add = 0;\n>       -+\tstruct tempfile *orig_index = NULL;\n>        +\tchar *orig_index_path = NULL;\n>         \n>         \trepo_config(repo, add_config, NULL);\n>       @@ builtin/add.c: int cmd_add(int argc,\n>         \t}\n>         \n>        +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n>       -+\t\tint fd_in, status;\n>       -+\t\tconst char *index_file = repo_get_index_file(repo);\n>       -+\t\tchar *template;\n>       -+\n>        +\t\trun_pre_add = 1;\n>       -+\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n>       -+\t\torig_index = xmks_tempfile(template);\n>       -+\t\tfree(template);\n>       -+\n>       -+\t\tfd_in = open(index_file, O_RDONLY);\n>       -+\t\tif (fd_in >= 0) {\n>       -+\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n>       -+\t\t\tif (close(fd_in))\n>       -+\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n>       -+\t\t\tif (close_tempfile_gently(orig_index))\n>       -+\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n>       -+\t\t\tif (status < 0)\n>       -+\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n>       -+\t\t} else if (errno == ENOENT) {\n>       -+\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n>       -+\t\t\tif (delete_tempfile(&orig_index))\n>       -+\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n>       -+\t\t} else {\n>       -+\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n>       -+\t\t}\n>       ++\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n>        +\t}\n>        +\n>         \ttransaction = odb_transaction_begin(repo->objects);\n>       @@ builtin/add.c: int cmd_add(int argc,\n>         \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n>        -\t    report_path_error(ps_matched, &pathspec))\n>        +\t    report_path_error(ps_matched, &pathspec)) {\n>       -+\t\tif (orig_index)\n>       -+\t\t\tdelete_tempfile(&orig_index);\n>        +\t\tfree(orig_index_path);\n>         \t\texit(128);\n>        +\t}\n>       @@ builtin/add.c: int cmd_add(int argc,\n>        -\tif (write_locked_index(repo->index, &lock_file,\n>        -\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n>        -\t\tdie(_(\"unable to write new index file\"));\n>       -+\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n>       ++\tif (run_pre_add && repo->index->cache_changed) {\n>        +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n>        +\n>       -+\t\tif (write_locked_index(repo->index, &lock_file, 0))\n>       -+\t\t\tdie(_(\"unable to write new index file\"));\n>       ++\t\tif (write_locked_index(repo->index, &lock_file,\n>       ++\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n>       ++\t\t\tdie(_(\"unable to write proposed index\"));\n>        +\n>       -+\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n>       -+\t\t\t\t\t     orig_index_path);\n>       ++\t\tstrvec_push(&opt.args, orig_index_path);\n>        +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n>        +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n>        +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n>        +\t\t\texit_status = 1;\n>       ++\t\t} else if (commit_lock_file(&lock_file)) {\n>       ++\t\t\tdie(_(\"unable to write new index file\"));\n>        +\t\t} else {\n>       -+\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n>       -+\t\t\t\tdie(_(\"unable to write new index file\"));\n>       ++\t\t\trun_hooks_l(repo, \"post-index-change\",\n>       ++\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n>       ++\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n>       ++\t\t\t\t    NULL);\n>        +\t\t}\n>       ++\t\trepo->index->updated_workdir = 0;\n>       ++\t\trepo->index->updated_skipworktree = 0;\n>        +\t} else {\n>        +\t\tif (write_locked_index(repo->index, &lock_file,\n>        +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n>        +\t\t\tdie(_(\"unable to write new index file\"));\n>        +\t}\n>        +\n>       -+\tdelete_tempfile(&orig_index);\n>        +\tfree(orig_index_path);\n>         \n>         \tfree(ps_matched);\n>         \tdir_clear(&dir);\n>        \n>       + ## read-cache-ll.h ##\n>       +@@ read-cache-ll.h: int is_index_unborn(struct index_state *);\n>       + /* For use with `write_locked_index()`. */\n>       + #define COMMIT_LOCK\t\t(1 << 0)\n>       + #define SKIP_IF_UNCHANGED\t(1 << 1)\n>       ++#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n>       +\n>       + /*\n>       +  * Write the index while holding an already-taken lock. Close the lock,\n>       +\n>       + ## read-cache.c ##\n>       +@@ read-cache.c: static int do_write_locked_index(struct index_state *istate,\n>       + \telse\n>       + \t\tret = close_lock_file_gently(lock);\n>       +\n>       +-\trun_hooks_l(the_repository, \"post-index-change\",\n>       +-\t\t    istate->updated_workdir ? \"1\" : \"0\",\n>       +-\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n>       +-\tistate->updated_workdir = 0;\n>       +-\tistate->updated_skipworktree = 0;\n>       +-\n>       ++\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n>       ++\t\trun_hooks_l(the_repository, \"post-index-change\",\n>       ++\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n>       ++\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n>       ++\t\tistate->updated_workdir = 0;\n>       ++\t\tistate->updated_skipworktree = 0;\n>       ++\t}\n>       + \treturn ret;\n>       + }\n>       +\n>       +\n>         ## t/meson.build ##\n>        @@ t/meson.build: integration_tests = [\n>           't3703-add-magic-pathspec.sh',\n>       @@ t/t3706-pre-add-hook.sh (new)\n>        +\ttest_path_is_missing actual\n>        +'\n>        +\n>       -+test_expect_success 'hook receives original and proposed index as arguments' '\n>       -+\ttest_when_finished \"rm -f tracked expected hook-ran\" &&\n>       -+\techo \"initial\" >tracked &&\n>       -+\tgit add tracked &&\n>       -+\tgit commit -m \"initial\" &&\n>       ++test_expect_success 'setup for path-based tests' '\n>       ++\tgit add file &&\n>       ++\tgit commit -m \"initial\"\n>       ++'\n>       ++\n>       ++test_expect_success 'hook receives index-path and lockfile-path arguments' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n>       ++\t\t\t    expect-index expect-lockpath\" &&\n>       ++\techo staged >staged &&\n>       ++\tcat >expect-count <<-\\EOF &&\n>       ++\t2\n>       ++\tEOF\n>        +\ttest_hook pre-add <<-\\EOF &&\n>       -+\ttest $# -eq 2 &&\n>       -+\ttest -f \"$1\" &&\n>       -+\ttest -f \"$2\" &&\n>       -+\techo pass >hook-ran\n>       ++\techo \"$#\" >arg-count &&\n>       ++\techo \"$1\" >arg-one &&\n>       ++\techo \"$2\" >arg-two &&\n>       ++\ttest \"$1\" != \"$2\" &&\n>       ++\ttest -r \"$2\"\n>        +\tEOF\n>       ++\tgit add staged &&\n>       ++\ttest_cmp expect-count arg-count &&\n>       ++\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n>       ++\ttest_cmp expect-index arg-one &&\n>       ++\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n>       ++\ttest_cmp expect-lockpath arg-two\n>       ++'\n>        +\n>       -+\techo \"modified\" >tracked &&\n>       -+\tgit add tracked &&\n>       -+\techo pass >expected &&\n>       -+\ttest_cmp expected hook-ran\n>       ++test_expect_success 'hook rejection leaves final index unchanged' '\n>       ++\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n>       ++\tcp .git/index index.before &&\n>       ++\ttest_hook pre-add <<-\\EOF &&\n>       ++\texit 1\n>       ++\tEOF\n>       ++\techo reject >reject &&\n>       ++\ttest_must_fail git add reject &&\n>       ++\ttest_cmp_bin index.before .git/index &&\n>       ++\ttest_path_is_missing .git/index.lock\n>        +'\n>        +\n>       -+test_expect_success 'hook handles first add with no existing index' '\n>       -+\ttest_when_finished \"rm -rf no-index\" &&\n>       -+\ttest_create_repo no-index &&\n>       -+\techo ok >no-index/expected &&\n>       -+\ttest_hook -C no-index pre-add <<-\\EOF &&\n>       -+\ttest $# -eq 2 &&\n>       ++test_expect_success 'missing pre-existing index path treated as empty' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n>       ++\trm -f .git/index &&\n>       ++\ttest_hook pre-add <<-\\EOF &&\n>       ++\techo \"$1\" >arg-one &&\n>        +\ttest ! -e \"$1\" &&\n>       -+\ttest -f \"$2\" &&\n>       -+\techo ok >hook-ran\n>       ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n>       ++\tsort after.raw >after\n>        +\tEOF\n>       -+\n>       -+\techo first >no-index/file &&\n>       -+\tgit -C no-index add file &&\n>       -+\ttest_cmp no-index/expected no-index/hook-ran\n>       ++\techo newfile >newfile &&\n>       ++\tgit add newfile &&\n>       ++\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n>       ++\ttest_cmp expect-index arg-one &&\n>       ++\tgrep \"^newfile$\" after &&\n>       ++\tgrep \"^file$\" after\n>        +'\n>        +\n>       -+test_expect_success 'hook is not invoked with --dry-run (show-only)' '\n>       -+\ttest_when_finished \"rm -f actual\" &&\n>       ++test_expect_success 'hook respects GIT_INDEX_FILE' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n>       ++\t\t\t    alt-index alt-index.lock\" &&\n>        +\ttest_hook pre-add <<-\\EOF &&\n>       -+\techo should-not-run >>actual\n>       -+\texit 1\n>       ++\techo \"$1\" >arg-one &&\n>       ++\techo \"$2\" >arg-two\n>        +\tEOF\n>       ++\techo changed >>file &&\n>       ++\tGIT_INDEX_FILE=alt-index git add file &&\n>       ++\techo \"$PWD/alt-index\" >expect-index &&\n>       ++\ttest_cmp expect-index arg-one &&\n>       ++\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n>       ++\ttest_cmp expect-lockpath arg-two\n>       ++'\n>        +\n>       -+\techo content >file &&\n>       -+\tgit add --dry-run file &&\n>       -+\ttest_path_is_missing actual\n>       ++test_expect_success 'setup for mixed-result tests' '\n>       ++\techo \"*.ignored\" >.gitignore &&\n>       ++\tgit add .gitignore &&\n>       ++\tgit commit -m \"add gitignore\"\n>        +'\n>        +\n>       -+test_expect_success 'hook is invoked with git add -u' '\n>       -+\ttest_when_finished \"rm -f actual expected file\" &&\n>       -+\techo \"initial\" >file &&\n>       -+\tgit add file &&\n>       -+\tgit commit -m \"initial\" &&\n>       -+\techo \"pre-add\" >expected &&\n>       ++test_expect_success 'mixed-result add invokes pre-add hook' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n>       ++\techo changed >>file &&\n>       ++\techo ignored >bad.ignored &&\n>       ++\tcp .git/index index.before &&\n>        +\ttest_hook pre-add <<-\\EOF &&\n>       -+\techo pre-add >>actual\n>       ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n>       ++\tgrep \"^file$\" proposed &&\n>       ++\techo invoked >hook-ran &&\n>       ++\texit 1\n>        +\tEOF\n>       -+\n>       -+\techo modified >file &&\n>       -+\tgit add -u &&\n>       -+\ttest_cmp expected actual\n>       ++\ttest_must_fail git add file bad.ignored &&\n>       ++\ttest_path_is_file hook-ran &&\n>       ++\ttest_cmp_bin index.before .git/index &&\n>       ++\ttest_path_is_missing .git/index.lock\n>        +'\n>        +\n>       -+test_expect_success 'hook can compare original and proposed index' '\n>       -+\ttest_when_finished \"rm -f old-raw new-raw old-list new-list \\\n>       -+\t\t\t    expected-old expected-new\" &&\n>       -+\techo \"initial\" >file1 &&\n>       -+\techo \"initial\" >file2 &&\n>       -+\tgit add file1 file2 &&\n>       -+\tgit commit -m \"initial\" &&\n>       -+\techo \"staged-before\" >file1 &&\n>       -+\tgit add file1 &&\n>       ++test_expect_success 'mixed-result add stages tracked update on approve' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n>       ++\techo changed >>file &&\n>       ++\techo ignored >bad.ignored &&\n>        +\ttest_hook pre-add <<-\\EOF &&\n>       -+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n>       -+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n>       -+\tsort old-raw >old-list &&\n>       -+\tsort new-raw >new-list\n>       ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n>       ++\tgrep \"^file$\" proposed &&\n>       ++\techo invoked >hook-ran\n>        +\tEOF\n>       -+\n>       -+\techo \"modified\" >file2 &&\n>       -+\tgit add file2 &&\n>       -+\techo file1 >expected-old &&\n>       -+\tprintf \"%s\\n\" file1 file2 >expected-new &&\n>       -+\ttest_cmp expected-old old-list &&\n>       -+\ttest_cmp expected-new new-list\n>       ++\ttest_must_fail git add file bad.ignored &&\n>       ++\ttest_path_is_file hook-ran &&\n>       ++\tgit diff --cached --name-only HEAD >staged &&\n>       ++\tgrep \"^file$\" staged &&\n>       ++\ttest_path_is_missing .git/index.lock\n>        +'\n>        +\n>       -+test_expect_success 'hook rejection rolls back index unchanged' '\n>       -+\ttest_when_finished \"rm -f file before after old-raw new-raw \\\n>       -+\t\t\t    old-list new-list expected-old expected-new\" &&\n>       -+\techo \"initial\" >file &&\n>       ++test_expect_success 'post-index-change fires after pre-add approval' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f hook-order expect lockfile-present\" &&\n>       ++\ttest_hook pre-add <<-\\EOF &&\n>       ++\techo pre >>hook-order\n>       ++\tEOF\n>       ++\ttest_hook post-index-change <<-\\EOF &&\n>       ++\tif test -f \".git/index.lock\"\n>       ++\tthen\n>       ++\t\techo locked >lockfile-present\n>       ++\tfi\n>       ++\techo post >>hook-order\n>       ++\tEOF\n>       ++\techo updated >>file &&\n>        +\tgit add file &&\n>       -+\tgit commit -m \"initial\" &&\n>       -+\tgit diff --cached --name-only HEAD >before &&\n>       ++\tcat >expect <<-\\EOF &&\n>       ++\tpre\n>       ++\tpost\n>       ++\tEOF\n>       ++\ttest_cmp expect hook-order &&\n>       ++\ttest_path_is_missing lockfile-present\n>       ++'\n>       ++\n>       ++test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f index.before hook-order expect\" &&\n>       ++\tcp .git/index index.before &&\n>        +\ttest_hook pre-add <<-\\EOF &&\n>       -+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n>       -+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n>       -+\tsort old-raw >old-list &&\n>       -+\tsort new-raw >new-list &&\n>       ++\techo pre >>hook-order &&\n>        +\texit 1\n>        +\tEOF\n>       -+\n>       -+\techo \"modified\" >file &&\n>       ++\ttest_hook post-index-change <<-\\EOF &&\n>       ++\techo post >>hook-order\n>       ++\tEOF\n>       ++\techo reject >>file &&\n>        +\ttest_must_fail git add file &&\n>       -+\tgit diff --cached --name-only HEAD >after &&\n>       -+\ttest_cmp before after &&\n>       -+\t: >expected-old &&\n>       -+\techo file >expected-new &&\n>       -+\ttest_cmp expected-old old-list &&\n>       -+\ttest_cmp expected-new new-list\n>       ++\techo pre >expect &&\n>       ++\ttest_cmp expect hook-order &&\n>       ++\ttest_cmp_bin index.before .git/index &&\n>       ++\ttest_path_is_missing .git/index.lock\n>       ++'\n>       ++\n>       ++test_expect_success '--dry-run does not invoke hook' '\n>       ++\ttest_when_finished \"rm -f hook-ran dry\" &&\n>       ++\ttest_hook pre-add <<-\\EOF &&\n>       ++\techo invoked >hook-ran\n>       ++\tEOF\n>       ++\techo dry >dry &&\n>       ++\tgit add --dry-run dry &&\n>       ++\ttest_path_is_missing hook-ran\n>       ++'\n>       ++\n>       ++test_expect_success 'hook runs for git add -u' '\n>       ++\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n>       ++\ttest_hook pre-add <<-\\EOF &&\n>       ++\techo invoked >hook-ran\n>       ++\tEOF\n>       ++\techo changed >>file &&\n>       ++\tgit add -u &&\n>       ++\ttest_path_is_file hook-ran\n>        +'\n>        +\n>        +test_expect_success 'hook example: block .env files' '\n>       -+\ttest_when_finished \"rm -f .env safe.txt new-paths\" &&\n>       -+\techo \"initial\" >base &&\n>       -+\tgit add base &&\n>       -+\tgit commit -m \"initial\" &&\n>       ++\ttest_when_finished \"git reset --hard &&\n>       ++\t\t\t    rm -f .env safe.txt new-paths\" &&\n>        +\ttest_hook pre-add <<-\\EOF &&\n>        +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n>        +\twhile read path\n>        +\tdo\n>        +\t\tcase \"$path\" in\n>       -+\t\t*.env|.env)\n>       ++\t\t*.env)\n>        +\t\t\techo \"error: $path must not be staged\" >&2\n>        +\t\t\texit 1\n>        +\t\t\t;;\n>        +\t\tesac\n>        +\tdone <new-paths\n>        +\tEOF\n>       -+\n>        +\techo \"DB_PASS=secret\" >.env &&\n>        +\ttest_must_fail git add .env &&\n>        +\techo \"safe content\" >safe.txt &&\n>       @@ t/t3706-pre-add-hook.sh (new)\n>        +'\n>        +\n>        +test_expect_success 'hook example: block secrets in content' '\n>       -+\ttest_when_finished \"rm -f config.txt secret\" &&\n>       -+\techo \"initial\" >config.txt &&\n>       -+\tgit add config.txt &&\n>       -+\tgit commit -m \"initial\" &&\n>       ++\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n>        +\ttest_hook pre-add <<-\\EOF &&\n>        +\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n>       -+\tif grep -qE \"(API_KEY|SECRET_KEY|PRIVATE_KEY)=\" secret\n>       ++\tif grep -q \"API_KEY=\" secret ||\n>       ++\t   grep -q \"SECRET_KEY=\" secret ||\n>       ++\t   grep -q \"PRIVATE_KEY=\" secret\n>        +\tthen\n>        +\t\techo \"error: staged content contains secrets\" >&2\n>        +\t\texit 1\n>        +\tfi\n>        +\tEOF\n>       -+\n>        +\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n>        +\ttest_must_fail git add config.txt &&\n>        +\techo \"LOG_LEVEL=debug\" >config.txt &&\n> \n> \n>   Documentation/git-add.adoc  |  11 +-\n>   Documentation/githooks.adoc |  30 ++++\n>   builtin/add.c               |  47 +++++-\n>   read-cache-ll.h             |   1 +\n>   read-cache.c                |  13 +-\n>   t/meson.build               |   1 +\n>   t/t3706-pre-add-hook.sh     | 289 ++++++++++++++++++++++++++++++++++++\n>   7 files changed, 381 insertions(+), 11 deletions(-)\n>   create mode 100755 t/t3706-pre-add-hook.sh\n> \n> diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\n> index 6192daeb03..b47751acca 100644\n> --- a/Documentation/git-add.adoc\n> +++ b/Documentation/git-add.adoc\n> @@ -10,7 +10,7 @@ SYNOPSIS\n>   [synopsis]\n>   git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n>   \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n> -\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n> +\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n>   \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n>   \t[--] [<pathspec>...]\n>   \n> @@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n>   filename of an ignored file, `git add` will fail with a list of ignored\n>   files. Otherwise it will silently ignore the file.\n>   \n> +A `pre-add` hook can be run to inspect or reject the proposed index update\n> +after `git add` computes staging and writes it to the index lockfile,\n> +but before writing it to the final index. See linkgit:githooks[5].\n> +\n>   Please see linkgit:git-commit[1] for alternative ways to add content to a\n>   commit.\n>   \n> @@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n>   \tDon't add the file(s), but only refresh their stat()\n>   \tinformation in the index.\n>   \n> +`--no-verify`::\n> +\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n> +\tmore information about hooks.\n> +\n>   `--ignore-errors`::\n>   \tIf some files could not be added because of errors indexing\n>   \tthem, do not abort the operation, but continue adding the\n> @@ -451,6 +459,7 @@ linkgit:git-reset[1]\n>   linkgit:git-mv[1]\n>   linkgit:git-commit[1]\n>   linkgit:git-update-index[1]\n> +linkgit:githooks[5]\n>   \n>   GIT\n>   ---\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index 056553788d..657e14d306 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n>   This hook is meant primarily for notification, and cannot affect\n>   the outcome of `git am`.\n>   \n> +pre-add\n> +~~~~~~~\n> +\n> +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n> +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> +`--edit`, or `--dry-run`.\n> +\n> +It takes two parameters: the path to the index file for this invocation\n> +of `git add`, and the path to the lockfile containing the proposed\n> +index after staging. It does not read from standard input. If no index\n> +exists yet, the first parameter names a path that does not exist and\n> +should be treated as an empty index.\n> +\n> +The hook is invoked after the index has been updated in memory and\n> +written to the lockfile, but before it is committed to the final index\n> +path. Exiting with a non-zero status causes `git add` to reject the\n> +proposed state, roll back the lockfile, and leave the index unchanged.\n> +Exiting with zero status allows the index update to be committed.\n> +\n> +Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n> +set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n> +`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n> +\n> +This hook can be used to prevent staging of files based on names, content,\n> +or sizes (e.g., to block `.env` files, secret keys, or large files).\n> +\n> +This hook is not invoked by `git commit -a` or `git commit --include`\n> +which still can run the `pre-commit` hook, providing a control point at\n> +commit time.\n> +\n>   pre-commit\n>   ~~~~~~~~~~\n>   \n> diff --git a/builtin/add.c b/builtin/add.c\n> index 32709794b3..d4d004a35b 100644\n> --- a/builtin/add.c\n> +++ b/builtin/add.c\n> @@ -25,6 +25,8 @@\n>   #include \"strvec.h\"\n>   #include \"submodule.h\"\n>   #include \"add-interactive.h\"\n> +#include \"hook.h\"\n> +#include \"abspath.h\"\n>   \n>   static const char * const builtin_add_usage[] = {\n>   \tN_(\"git add [<options>] [--] <pathspec>...\"),\n> @@ -36,6 +38,7 @@ static int take_worktree_changes;\n>   static int add_renormalize;\n>   static int pathspec_file_nul;\n>   static int include_sparse;\n> +static int no_verify;\n>   static const char *pathspec_from_file;\n>   \n>   static int chmod_pathspec(struct repository *repo,\n> @@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {\n>   \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n>   \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n>   \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n> +\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n>   \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n>   \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n>   \t\t   N_(\"override the executable bit of the listed files\")),\n> @@ -391,6 +395,8 @@ int cmd_add(int argc,\n>   \tchar *ps_matched = NULL;\n>   \tstruct lock_file lock_file = LOCK_INIT;\n>   \tstruct odb_transaction *transaction;\n> +\tint run_pre_add = 0;\n> +\tchar *orig_index_path = NULL;\n>   \n>   \trepo_config(repo, add_config, NULL);\n>   \n> @@ -576,6 +582,11 @@ int cmd_add(int argc,\n>   \t\tstring_list_clear(&only_match_skip_worktree, 0);\n>   \t}\n>   \n> +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> +\t\trun_pre_add = 1;\n> +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n> +\t}\n> +\n>   \ttransaction = odb_transaction_begin(repo->objects);\n>   \n>   \tps_matched = xcalloc(pathspec.nr, 1);\n> @@ -587,8 +598,10 @@ int cmd_add(int argc,\n>   \t\t\t\t\t\t  include_sparse, flags);\n>   \n>   \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n> -\t    report_path_error(ps_matched, &pathspec))\n> +\t    report_path_error(ps_matched, &pathspec)) {\n> +\t\tfree(orig_index_path);\n>   \t\texit(128);\n> +\t}\n>   \n>   \tif (add_new_files)\n>   \t\texit_status |= add_files(repo, &dir, flags);\n> @@ -598,9 +611,35 @@ int cmd_add(int argc,\n>   \todb_transaction_commit(transaction);\n>   \n>   finish:\n> -\tif (write_locked_index(repo->index, &lock_file,\n> -\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> -\t\tdie(_(\"unable to write new index file\"));\n> +\tif (run_pre_add && repo->index->cache_changed) {\n> +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> +\n> +\t\tif (write_locked_index(repo->index, &lock_file,\n> +\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n> +\t\t\tdie(_(\"unable to write proposed index\"));\n> +\n> +\t\tstrvec_push(&opt.args, orig_index_path);\n> +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n> +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n> +\t\t\texit_status = 1;\n> +\t\t} else if (commit_lock_file(&lock_file)) {\n> +\t\t\tdie(_(\"unable to write new index file\"));\n> +\t\t} else {\n> +\t\t\trun_hooks_l(repo, \"post-index-change\",\n> +\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n> +\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n> +\t\t\t\t    NULL);\n> +\t\t}\n> +\t\trepo->index->updated_workdir = 0;\n> +\t\trepo->index->updated_skipworktree = 0;\n> +\t} else {\n> +\t\tif (write_locked_index(repo->index, &lock_file,\n> +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> +\t\t\tdie(_(\"unable to write new index file\"));\n> +\t}\n> +\n> +\tfree(orig_index_path);\n>   \n>   \tfree(ps_matched);\n>   \tdir_clear(&dir);\n> diff --git a/read-cache-ll.h b/read-cache-ll.h\n> index 71b49d9af4..a43971c07e 100644\n> --- a/read-cache-ll.h\n> +++ b/read-cache-ll.h\n> @@ -284,6 +284,7 @@ int is_index_unborn(struct index_state *);\n>   /* For use with `write_locked_index()`. */\n>   #define COMMIT_LOCK\t\t(1 << 0)\n>   #define SKIP_IF_UNCHANGED\t(1 << 1)\n> +#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n>   \n>   /*\n>    * Write the index while holding an already-taken lock. Close the lock,\n> diff --git a/read-cache.c b/read-cache.c\n> index 0c07c3aef7..5051cda4ce 100644\n> --- a/read-cache.c\n> +++ b/read-cache.c\n> @@ -3161,12 +3161,13 @@ static int do_write_locked_index(struct index_state *istate,\n>   \telse\n>   \t\tret = close_lock_file_gently(lock);\n>   \n> -\trun_hooks_l(the_repository, \"post-index-change\",\n> -\t\t    istate->updated_workdir ? \"1\" : \"0\",\n> -\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n> -\tistate->updated_workdir = 0;\n> -\tistate->updated_skipworktree = 0;\n> -\n> +\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n> +\t\trun_hooks_l(the_repository, \"post-index-change\",\n> +\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n> +\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n> +\t\tistate->updated_workdir = 0;\n> +\t\tistate->updated_skipworktree = 0;\n> +\t}\n>   \treturn ret;\n>   }\n>   \n> diff --git a/t/meson.build b/t/meson.build\n> index f80e366cff..2419a9adbb 100644\n> --- a/t/meson.build\n> +++ b/t/meson.build\n> @@ -415,6 +415,7 @@ integration_tests = [\n>     't3703-add-magic-pathspec.sh',\n>     't3704-add-pathspec-file.sh',\n>     't3705-add-sparse-checkout.sh',\n> +  't3706-pre-add-hook.sh',\n>     't3800-mktag.sh',\n>     't3900-i18n-commit.sh',\n>     't3901-i18n-patch.sh',\n> diff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\n> new file mode 100755\n> index 0000000000..f5092f0727\n> --- /dev/null\n> +++ b/t/t3706-pre-add-hook.sh\n> @@ -0,0 +1,289 @@\n> +#!/bin/sh\n> +\n> +test_description='pre-add hook tests\n> +\n> +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).'\n> +\n> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n> +\n> +. ./test-lib.sh\n> +\n> +test_expect_success 'with no hook' '\n> +\ttest_when_finished \"rm -f actual\" &&\n> +\techo content >file &&\n> +\tgit add file &&\n> +\ttest_path_is_missing actual\n> +'\n> +\n> +test_expect_success POSIXPERM 'with non-executable hook' '\n> +\ttest_when_finished \"rm -f actual\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo should-not-run >>actual\n> +\texit 1\n> +\tEOF\n> +\tchmod -x .git/hooks/pre-add &&\n> +\n> +\techo content >file &&\n> +\tgit add file &&\n> +\ttest_path_is_missing actual\n> +'\n> +\n> +test_expect_success '--no-verify with no hook' '\n> +\techo content >file &&\n> +\tgit add --no-verify file &&\n> +\ttest_path_is_missing actual\n> +'\n> +\n> +test_expect_success 'with succeeding hook' '\n> +\ttest_when_finished \"rm -f actual expected\" &&\n> +\techo \"pre-add\" >expected &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo pre-add >>actual\n> +\tEOF\n> +\n> +\techo content >file &&\n> +\tgit add file &&\n> +\ttest_cmp expected actual\n> +'\n> +\n> +test_expect_success 'with failing hook' '\n> +\ttest_when_finished \"rm -f actual\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo pre-add-rejected >>actual\n> +\texit 1\n> +\tEOF\n> +\n> +\techo content >file &&\n> +\ttest_must_fail git add file\n> +'\n> +\n> +test_expect_success '--no-verify with failing hook' '\n> +\ttest_when_finished \"rm -f actual\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo should-not-run >>actual\n> +\texit 1\n> +\tEOF\n> +\n> +\techo content >file &&\n> +\tgit add --no-verify file &&\n> +\ttest_path_is_missing actual\n> +'\n> +\n> +test_expect_success 'setup for path-based tests' '\n> +\tgit add file &&\n> +\tgit commit -m \"initial\"\n> +'\n> +\n> +test_expect_success 'hook receives index-path and lockfile-path arguments' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n> +\t\t\t    expect-index expect-lockpath\" &&\n> +\techo staged >staged &&\n> +\tcat >expect-count <<-\\EOF &&\n> +\t2\n> +\tEOF\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo \"$#\" >arg-count &&\n> +\techo \"$1\" >arg-one &&\n> +\techo \"$2\" >arg-two &&\n> +\ttest \"$1\" != \"$2\" &&\n> +\ttest -r \"$2\"\n> +\tEOF\n> +\tgit add staged &&\n> +\ttest_cmp expect-count arg-count &&\n> +\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n> +\ttest_cmp expect-index arg-one &&\n> +\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n> +\ttest_cmp expect-lockpath arg-two\n> +'\n> +\n> +test_expect_success 'hook rejection leaves final index unchanged' '\n> +\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n> +\tcp .git/index index.before &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\texit 1\n> +\tEOF\n> +\techo reject >reject &&\n> +\ttest_must_fail git add reject &&\n> +\ttest_cmp_bin index.before .git/index &&\n> +\ttest_path_is_missing .git/index.lock\n> +'\n> +\n> +test_expect_success 'missing pre-existing index path treated as empty' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n> +\trm -f .git/index &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo \"$1\" >arg-one &&\n> +\ttest ! -e \"$1\" &&\n> +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n> +\tsort after.raw >after\n> +\tEOF\n> +\techo newfile >newfile &&\n> +\tgit add newfile &&\n> +\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n> +\ttest_cmp expect-index arg-one &&\n> +\tgrep \"^newfile$\" after &&\n> +\tgrep \"^file$\" after\n> +'\n> +\n> +test_expect_success 'hook respects GIT_INDEX_FILE' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n> +\t\t\t    alt-index alt-index.lock\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo \"$1\" >arg-one &&\n> +\techo \"$2\" >arg-two\n> +\tEOF\n> +\techo changed >>file &&\n> +\tGIT_INDEX_FILE=alt-index git add file &&\n> +\techo \"$PWD/alt-index\" >expect-index &&\n> +\ttest_cmp expect-index arg-one &&\n> +\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n> +\ttest_cmp expect-lockpath arg-two\n> +'\n> +\n> +test_expect_success 'setup for mixed-result tests' '\n> +\techo \"*.ignored\" >.gitignore &&\n> +\tgit add .gitignore &&\n> +\tgit commit -m \"add gitignore\"\n> +'\n> +\n> +test_expect_success 'mixed-result add invokes pre-add hook' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n> +\techo changed >>file &&\n> +\techo ignored >bad.ignored &&\n> +\tcp .git/index index.before &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n> +\tgrep \"^file$\" proposed &&\n> +\techo invoked >hook-ran &&\n> +\texit 1\n> +\tEOF\n> +\ttest_must_fail git add file bad.ignored &&\n> +\ttest_path_is_file hook-ran &&\n> +\ttest_cmp_bin index.before .git/index &&\n> +\ttest_path_is_missing .git/index.lock\n> +'\n> +\n> +test_expect_success 'mixed-result add stages tracked update on approve' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n> +\techo changed >>file &&\n> +\techo ignored >bad.ignored &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n> +\tgrep \"^file$\" proposed &&\n> +\techo invoked >hook-ran\n> +\tEOF\n> +\ttest_must_fail git add file bad.ignored &&\n> +\ttest_path_is_file hook-ran &&\n> +\tgit diff --cached --name-only HEAD >staged &&\n> +\tgrep \"^file$\" staged &&\n> +\ttest_path_is_missing .git/index.lock\n> +'\n> +\n> +test_expect_success 'post-index-change fires after pre-add approval' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f hook-order expect lockfile-present\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo pre >>hook-order\n> +\tEOF\n> +\ttest_hook post-index-change <<-\\EOF &&\n> +\tif test -f \".git/index.lock\"\n> +\tthen\n> +\t\techo locked >lockfile-present\n> +\tfi\n> +\techo post >>hook-order\n> +\tEOF\n> +\techo updated >>file &&\n> +\tgit add file &&\n> +\tcat >expect <<-\\EOF &&\n> +\tpre\n> +\tpost\n> +\tEOF\n> +\ttest_cmp expect hook-order &&\n> +\ttest_path_is_missing lockfile-present\n> +'\n> +\n> +test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f index.before hook-order expect\" &&\n> +\tcp .git/index index.before &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo pre >>hook-order &&\n> +\texit 1\n> +\tEOF\n> +\ttest_hook post-index-change <<-\\EOF &&\n> +\techo post >>hook-order\n> +\tEOF\n> +\techo reject >>file &&\n> +\ttest_must_fail git add file &&\n> +\techo pre >expect &&\n> +\ttest_cmp expect hook-order &&\n> +\ttest_cmp_bin index.before .git/index &&\n> +\ttest_path_is_missing .git/index.lock\n> +'\n> +\n> +test_expect_success '--dry-run does not invoke hook' '\n> +\ttest_when_finished \"rm -f hook-ran dry\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo invoked >hook-ran\n> +\tEOF\n> +\techo dry >dry &&\n> +\tgit add --dry-run dry &&\n> +\ttest_path_is_missing hook-ran\n> +'\n> +\n> +test_expect_success 'hook runs for git add -u' '\n> +\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\techo invoked >hook-ran\n> +\tEOF\n> +\techo changed >>file &&\n> +\tgit add -u &&\n> +\ttest_path_is_file hook-ran\n> +'\n> +\n> +test_expect_success 'hook example: block .env files' '\n> +\ttest_when_finished \"git reset --hard &&\n> +\t\t\t    rm -f .env safe.txt new-paths\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n> +\twhile read path\n> +\tdo\n> +\t\tcase \"$path\" in\n> +\t\t*.env)\n> +\t\t\techo \"error: $path must not be staged\" >&2\n> +\t\t\texit 1\n> +\t\t\t;;\n> +\t\tesac\n> +\tdone <new-paths\n> +\tEOF\n> +\techo \"DB_PASS=secret\" >.env &&\n> +\ttest_must_fail git add .env &&\n> +\techo \"safe content\" >safe.txt &&\n> +\tgit add safe.txt\n> +'\n> +\n> +test_expect_success 'hook example: block secrets in content' '\n> +\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n> +\ttest_hook pre-add <<-\\EOF &&\n> +\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n> +\tif grep -q \"API_KEY=\" secret ||\n> +\t   grep -q \"SECRET_KEY=\" secret ||\n> +\t   grep -q \"PRIVATE_KEY=\" secret\n> +\tthen\n> +\t\techo \"error: staged content contains secrets\" >&2\n> +\t\texit 1\n> +\tfi\n> +\tEOF\n> +\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n> +\ttest_must_fail git add config.txt &&\n> +\techo \"LOG_LEVEL=debug\" >config.txt &&\n> +\tgit add config.txt\n> +'\n> +\n> +test_done\n> \n> base-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4\n\n"},{"id":"537923","messageId":"pull.2045.v4.git.1772710566599.gitgitgadget@gmail.com","threadId":"64972","inReplyTo":"pull.2045.v3.git.1772171692465.gitgitgadget@gmail.com","subject":"[PATCH v4] add: support pre-add hook","fromName":"Chandra Kethi-Reddy via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-03-05T11:36:06Z","receivedAt":"2026-03-05T11:36:18Z","isPatch":true,"sender":{"key":"name:Chandra Kethi-Reddy","avatar":null},"body":"From: Chandra Kethi-Reddy <chandrakr@pm.me>\n\n\"git add\" has no hook that lets users inspect what is about to be\nstaged. Users who want to reject certain paths or content must\nwrap the command in a shell alias or wait for pre-commit, which\nfires too late to prevent staging.\n\nIntroduce a \"pre-add\" hook so that users can inspect or reject\nproposed index updates at staging time.\n\n  $1 -- index path used by this invocation (may not exist yet)\n  $2 -- lockfile path containing proposed staged index state\n\nHook authors can inspect the result with ordinary Git commands:\n\n  GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n\nBoth files should be treated as read-only. Exiting with non-zero\nstatus rejects the update and leaves the index unchanged.\n\nThe hook accepts or rejects the entire proposed update. Per-path\nfiltering is not supported.\n\nThe hook is bypassed with \"--no-verify\" and is not invoked for\n--interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\nwhich stages through its own code path.\n\nSigned-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n---\n    add: support pre-add hook\n    \n    \n    Summary\n    =======\n    \n     * v4 fixed various documentation/code refactoring issues and clarifies\n       that per-path filtering is not supported.\n    \n    \n    Notes\n    =====\n    \n     * This design intentionally trades ODB prevention for correctness of\n       hook inputs: blobs may already be written to object storage when the\n       hook runs, but hook rejection still leaves the on-disk index\n       unchanged\n     * AI Disclosure: Codex and Claude Code CLI were used to assist\n       drafting. All tests, code, and docs were committed by hand.\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v4\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v4\nPull-Request: https://github.com/gitgitgadget/git/pull/2045\n\nRange-diff vs v3:\n\n 1:  d0fb5f9da21 ! 1:  9383395bb06 add: support pre-add hook\n     @@ Commit message\n          wrap the command in a shell alias or wait for pre-commit, which\n          fires too late to prevent staging.\n      \n     -    Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n     -    new index state but before committing it to disk. The hook\n     -    receives two positional arguments:\n     +    Introduce a \"pre-add\" hook so that users can inspect or reject\n     +    proposed index updates at staging time.\n      \n            $1 -- index path used by this invocation (may not exist yet)\n            $2 -- lockfile path containing proposed staged index state\n      \n     -    While the lockfile is active the current index path remains readable\n     -    and unchanged, so a seperate copy is unnecessary. Hook authors can\n     -    inspect the computed result with ordinary tools:\n     +    Hook authors can inspect the result with ordinary Git commands:\n      \n            GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n      \n     -    without needing to interpret pathspec or mode flags as the proposed\n     -    index already reflects their effect.\n     +    Both files should be treated as read-only. Exiting with non-zero\n     +    status rejects the update and leaves the index unchanged.\n      \n     -    At the finish label, write_locked_index() writes the proposed index\n     -    to the lockfile without COMMIT_LOCK so commit_lock_file() can be\n     -    called seperately after the hook runs. However, do_write_locked_index()\n     -    unconditionally fires post-index-change after every write, and the\n     -    existing test suite (t7113) asserts that index.lock does not exist when\n     -    that hook fires. Tying the hook to COMMIT_LOCK would suppress it for\n     -    other callers that depend on it after a non-committed write (e.g.,\n     -    prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK\n     -    flag lets builtin/add.c suppress the automatic notification on just this\n     -    call, then emit post-index-change manually after commit_lock_file()\n     -    publishes the new index. If the hook rejects, rollback_lock_file()\n     -    discards the lockfile and the original index is left unchanged. When\n     -    no hook is installed the existing write_locked_index(COMMIT_LOCK |\n     -    SKIP_IF_UNCHANGED) path is taken.\n     -\n     -    The hook gate checks cache_changed regardless of exit_status so that\n     -    mixed-result adds (e.g., a tracked modification combined with an\n     -    ignored path) still run the hook when index content changes.\n     +    The hook accepts or rejects the entire proposed update. Per-path\n     +    filtering is not supported.\n      \n          The hook is bypassed with \"--no-verify\" and is not invoked for\n          --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n     @@ Commit message\n      \n       ## Documentation/git-add.adoc ##\n      @@ Documentation/git-add.adoc: SYNOPSIS\n     - [synopsis]\n       git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n       \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n     --\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n     -+\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n     - \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n     + \t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n     +-\t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n     ++\t[--no-verify] [--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n       \t[--] [<pathspec>...]\n       \n     + DESCRIPTION\n      @@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If you specify the exact\n       filename of an ignored file, `git add` will fail with a list of ignored\n       files. Otherwise it will silently ignore the file.\n       \n     -+A `pre-add` hook can be run to inspect or reject the proposed index update\n     -+after `git add` computes staging and writes it to the index lockfile,\n     -+but before writing it to the final index. See linkgit:githooks[5].\n     ++A `pre-add` hook can be used to reject `git add` (see linkgit:githooks[5]).\n      +\n       Please see linkgit:git-commit[1] for alternative ways to add content to a\n       commit.\n       \n     +-\n     + OPTIONS\n     + -------\n     + `<pathspec>...`::\n      @@ Documentation/git-add.adoc: for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n       \tDon't add the file(s), but only refresh their stat()\n       \tinformation in the index.\n     @@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com\n      +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n      +`--edit`, or `--dry-run`.\n      +\n     -+It takes two parameters: the path to the index file for this invocation\n     ++It takes two arguments: the path to the index file for this invocation\n      +of `git add`, and the path to the lockfile containing the proposed\n     -+index after staging. It does not read from standard input. If no index\n     -+exists yet, the first parameter names a path that does not exist and\n     -+should be treated as an empty index.\n     ++index after staging. If no index exists yet, the first argument names\n     ++a path that does not exist and should be treated as an empty index.\n      +\n      +The hook is invoked after the index has been updated in memory and\n      +written to the lockfile, but before it is committed to the final index\n      +path. Exiting with a non-zero status causes `git add` to reject the\n      +proposed state, roll back the lockfile, and leave the index unchanged.\n     -+Exiting with zero status allows the index update to be committed.\n     ++Exiting with zero status allows the index update to be committed. The\n     ++hook accepts or rejects the entire proposed update; per-path filtering\n     ++is not supported. Both files should be treated as read-only by the hook.\n      +\n     -+Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n     -+set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n     -+`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n     ++Hook authors may set `GIT_INDEX_FILE=\"$1\"` to inspect the current index\n     ++state and `GIT_INDEX_FILE=\"$2\"` to inspect the proposed index state.\n      +\n      +This hook can be used to prevent staging of files based on names, content,\n      +or sizes (e.g., to block `.env` files, secret keys, or large files).\n     @@ builtin/add.c: int cmd_add(int argc,\n       \ttransaction = odb_transaction_begin(repo->objects);\n       \n       \tps_matched = xcalloc(pathspec.nr, 1);\n     -@@ builtin/add.c: int cmd_add(int argc,\n     - \t\t\t\t\t\t  include_sparse, flags);\n     - \n     - \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n     --\t    report_path_error(ps_matched, &pathspec))\n     -+\t    report_path_error(ps_matched, &pathspec)) {\n     -+\t\tfree(orig_index_path);\n     - \t\texit(128);\n     -+\t}\n     - \n     - \tif (add_new_files)\n     - \t\texit_status |= add_files(repo, &dir, flags);\n      @@ builtin/add.c: int cmd_add(int argc,\n       \todb_transaction_commit(transaction);\n       \n     @@ builtin/add.c: int cmd_add(int argc,\n      +\t\t} else if (commit_lock_file(&lock_file)) {\n      +\t\t\tdie(_(\"unable to write new index file\"));\n      +\t\t} else {\n     -+\t\t\trun_hooks_l(repo, \"post-index-change\",\n     -+\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n     -+\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n     -+\t\t\t\t    NULL);\n     ++\t\t\temit_post_index_change(repo->index);\n      +\t\t}\n     -+\t\trepo->index->updated_workdir = 0;\n     -+\t\trepo->index->updated_skipworktree = 0;\n      +\t} else {\n      +\t\tif (write_locked_index(repo->index, &lock_file,\n      +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n     @@ read-cache-ll.h: int is_index_unborn(struct index_state *);\n       #define COMMIT_LOCK\t\t(1 << 0)\n       #define SKIP_IF_UNCHANGED\t(1 << 1)\n      +#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n     ++\n     ++void emit_post_index_change(struct index_state *istate);\n       \n       /*\n        * Write the index while holding an already-taken lock. Close the lock,\n     @@ read-cache.c: static int do_write_locked_index(struct index_state *istate,\n       \telse\n       \t\tret = close_lock_file_gently(lock);\n       \n     --\trun_hooks_l(the_repository, \"post-index-change\",\n     --\t\t    istate->updated_workdir ? \"1\" : \"0\",\n     --\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n     --\tistate->updated_workdir = 0;\n     --\tistate->updated_skipworktree = 0;\n     ++\tif (!(flags & SKIP_INDEX_CHANGE_HOOK))\n     ++\t\temit_post_index_change(istate);\n     ++\treturn ret;\n     ++}\n     ++\n     ++void emit_post_index_change(struct index_state *istate)\n     ++{\n     + \trun_hooks_l(the_repository, \"post-index-change\",\n     + \t\t    istate->updated_workdir ? \"1\" : \"0\",\n     + \t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n     + \tistate->updated_workdir = 0;\n     + \tistate->updated_skipworktree = 0;\n      -\n     -+\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n     -+\t\trun_hooks_l(the_repository, \"post-index-change\",\n     -+\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n     -+\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n     -+\t\tistate->updated_workdir = 0;\n     -+\t\tistate->updated_skipworktree = 0;\n     -+\t}\n     - \treturn ret;\n     +-\treturn ret;\n       }\n       \n     + static int write_split_index(struct index_state *istate,\n      \n       ## t/meson.build ##\n      @@ t/meson.build: integration_tests = [\n\n\n Documentation/git-add.adoc  |  10 +-\n Documentation/githooks.adoc |  30 ++++\n builtin/add.c               |  38 ++++-\n read-cache-ll.h             |   3 +\n read-cache.c                |   9 +-\n t/meson.build               |   1 +\n t/t3706-pre-add-hook.sh     | 289 ++++++++++++++++++++++++++++++++++++\n 7 files changed, 373 insertions(+), 7 deletions(-)\n create mode 100755 t/t3706-pre-add-hook.sh\n\ndiff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\nindex 6192daeb03..a3ff4ced83 100644\n--- a/Documentation/git-add.adoc\n+++ b/Documentation/git-add.adoc\n@@ -11,7 +11,7 @@ SYNOPSIS\n git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n \t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n-\t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n+\t[--no-verify] [--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n \t[--] [<pathspec>...]\n \n DESCRIPTION\n@@ -42,10 +42,11 @@ use the `--force` option to add ignored files. If you specify the exact\n filename of an ignored file, `git add` will fail with a list of ignored\n files. Otherwise it will silently ignore the file.\n \n+A `pre-add` hook can be used to reject `git add` (see linkgit:githooks[5]).\n+\n Please see linkgit:git-commit[1] for alternative ways to add content to a\n commit.\n \n-\n OPTIONS\n -------\n `<pathspec>...`::\n@@ -163,6 +164,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n \tDon't add the file(s), but only refresh their stat()\n \tinformation in the index.\n \n+`--no-verify`::\n+\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n+\tmore information about hooks.\n+\n `--ignore-errors`::\n \tIf some files could not be added because of errors indexing\n \tthem, do not abort the operation, but continue adding the\n@@ -451,6 +456,7 @@ linkgit:git-reset[1]\n linkgit:git-mv[1]\n linkgit:git-commit[1]\n linkgit:git-update-index[1]\n+linkgit:githooks[5]\n \n GIT\n ---\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 056553788d..90945a590e 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n This hook is meant primarily for notification, and cannot affect\n the outcome of `git am`.\n \n+pre-add\n+~~~~~~~\n+\n+This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n+`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n+`--edit`, or `--dry-run`.\n+\n+It takes two arguments: the path to the index file for this invocation\n+of `git add`, and the path to the lockfile containing the proposed\n+index after staging. If no index exists yet, the first argument names\n+a path that does not exist and should be treated as an empty index.\n+\n+The hook is invoked after the index has been updated in memory and\n+written to the lockfile, but before it is committed to the final index\n+path. Exiting with a non-zero status causes `git add` to reject the\n+proposed state, roll back the lockfile, and leave the index unchanged.\n+Exiting with zero status allows the index update to be committed. The\n+hook accepts or rejects the entire proposed update; per-path filtering\n+is not supported. Both files should be treated as read-only by the hook.\n+\n+Hook authors may set `GIT_INDEX_FILE=\"$1\"` to inspect the current index\n+state and `GIT_INDEX_FILE=\"$2\"` to inspect the proposed index state.\n+\n+This hook can be used to prevent staging of files based on names, content,\n+or sizes (e.g., to block `.env` files, secret keys, or large files).\n+\n+This hook is not invoked by `git commit -a` or `git commit --include`\n+which still can run the `pre-commit` hook, providing a control point at\n+commit time.\n+\n pre-commit\n ~~~~~~~~~~\n \ndiff --git a/builtin/add.c b/builtin/add.c\nindex 32709794b3..ca8b681d8a 100644\n--- a/builtin/add.c\n+++ b/builtin/add.c\n@@ -25,6 +25,8 @@\n #include \"strvec.h\"\n #include \"submodule.h\"\n #include \"add-interactive.h\"\n+#include \"hook.h\"\n+#include \"abspath.h\"\n \n static const char * const builtin_add_usage[] = {\n \tN_(\"git add [<options>] [--] <pathspec>...\"),\n@@ -36,6 +38,7 @@ static int take_worktree_changes;\n static int add_renormalize;\n static int pathspec_file_nul;\n static int include_sparse;\n+static int no_verify;\n static const char *pathspec_from_file;\n \n static int chmod_pathspec(struct repository *repo,\n@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {\n \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n+\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n \t\t   N_(\"override the executable bit of the listed files\")),\n@@ -391,6 +395,8 @@ int cmd_add(int argc,\n \tchar *ps_matched = NULL;\n \tstruct lock_file lock_file = LOCK_INIT;\n \tstruct odb_transaction *transaction;\n+\tint run_pre_add = 0;\n+\tchar *orig_index_path = NULL;\n \n \trepo_config(repo, add_config, NULL);\n \n@@ -576,6 +582,11 @@ int cmd_add(int argc,\n \t\tstring_list_clear(&only_match_skip_worktree, 0);\n \t}\n \n+\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n+\t\trun_pre_add = 1;\n+\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n+\t}\n+\n \ttransaction = odb_transaction_begin(repo->objects);\n \n \tps_matched = xcalloc(pathspec.nr, 1);\n@@ -598,9 +609,30 @@ int cmd_add(int argc,\n \todb_transaction_commit(transaction);\n \n finish:\n-\tif (write_locked_index(repo->index, &lock_file,\n-\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n-\t\tdie(_(\"unable to write new index file\"));\n+\tif (run_pre_add && repo->index->cache_changed) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n+\t\t\tdie(_(\"unable to write proposed index\"));\n+\n+\t\tstrvec_push(&opt.args, orig_index_path);\n+\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n+\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n+\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n+\t\t\texit_status = 1;\n+\t\t} else if (commit_lock_file(&lock_file)) {\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t\t} else {\n+\t\t\temit_post_index_change(repo->index);\n+\t\t}\n+\t} else {\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t}\n+\n+\tfree(orig_index_path);\n \n \tfree(ps_matched);\n \tdir_clear(&dir);\ndiff --git a/read-cache-ll.h b/read-cache-ll.h\nindex 71b49d9af4..eed1d74d99 100644\n--- a/read-cache-ll.h\n+++ b/read-cache-ll.h\n@@ -284,6 +284,9 @@ int is_index_unborn(struct index_state *);\n /* For use with `write_locked_index()`. */\n #define COMMIT_LOCK\t\t(1 << 0)\n #define SKIP_IF_UNCHANGED\t(1 << 1)\n+#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n+\n+void emit_post_index_change(struct index_state *istate);\n \n /*\n  * Write the index while holding an already-taken lock. Close the lock,\ndiff --git a/read-cache.c b/read-cache.c\nindex 0c07c3aef7..dfe8d8e4d7 100644\n--- a/read-cache.c\n+++ b/read-cache.c\n@@ -3161,13 +3161,18 @@ static int do_write_locked_index(struct index_state *istate,\n \telse\n \t\tret = close_lock_file_gently(lock);\n \n+\tif (!(flags & SKIP_INDEX_CHANGE_HOOK))\n+\t\temit_post_index_change(istate);\n+\treturn ret;\n+}\n+\n+void emit_post_index_change(struct index_state *istate)\n+{\n \trun_hooks_l(the_repository, \"post-index-change\",\n \t\t    istate->updated_workdir ? \"1\" : \"0\",\n \t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n \tistate->updated_workdir = 0;\n \tistate->updated_skipworktree = 0;\n-\n-\treturn ret;\n }\n \n static int write_split_index(struct index_state *istate,\ndiff --git a/t/meson.build b/t/meson.build\nindex f80e366cff..2419a9adbb 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -415,6 +415,7 @@ integration_tests = [\n   't3703-add-magic-pathspec.sh',\n   't3704-add-pathspec-file.sh',\n   't3705-add-sparse-checkout.sh',\n+  't3706-pre-add-hook.sh',\n   't3800-mktag.sh',\n   't3900-i18n-commit.sh',\n   't3901-i18n-patch.sh',\ndiff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\nnew file mode 100755\nindex 0000000000..f5092f0727\n--- /dev/null\n+++ b/t/t3706-pre-add-hook.sh\n@@ -0,0 +1,289 @@\n+#!/bin/sh\n+\n+test_description='pre-add hook tests\n+\n+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).'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'with no hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success POSIXPERM 'with non-executable hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\tchmod -x .git/hooks/pre-add &&\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success '--no-verify with no hook' '\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'with succeeding hook' '\n+\ttest_when_finished \"rm -f actual expected\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add-rejected >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\ttest_must_fail git add file\n+'\n+\n+test_expect_success '--no-verify with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'setup for path-based tests' '\n+\tgit add file &&\n+\tgit commit -m \"initial\"\n+'\n+\n+test_expect_success 'hook receives index-path and lockfile-path arguments' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n+\t\t\t    expect-index expect-lockpath\" &&\n+\techo staged >staged &&\n+\tcat >expect-count <<-\\EOF &&\n+\t2\n+\tEOF\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$#\" >arg-count &&\n+\techo \"$1\" >arg-one &&\n+\techo \"$2\" >arg-two &&\n+\ttest \"$1\" != \"$2\" &&\n+\ttest -r \"$2\"\n+\tEOF\n+\tgit add staged &&\n+\ttest_cmp expect-count arg-count &&\n+\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n+\ttest_cmp expect-lockpath arg-two\n+'\n+\n+test_expect_success 'hook rejection leaves final index unchanged' '\n+\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\techo reject >reject &&\n+\ttest_must_fail git add reject &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'missing pre-existing index path treated as empty' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n+\trm -f .git/index &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$1\" >arg-one &&\n+\ttest ! -e \"$1\" &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n+\tsort after.raw >after\n+\tEOF\n+\techo newfile >newfile &&\n+\tgit add newfile &&\n+\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\tgrep \"^newfile$\" after &&\n+\tgrep \"^file$\" after\n+'\n+\n+test_expect_success 'hook respects GIT_INDEX_FILE' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n+\t\t\t    alt-index alt-index.lock\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$1\" >arg-one &&\n+\techo \"$2\" >arg-two\n+\tEOF\n+\techo changed >>file &&\n+\tGIT_INDEX_FILE=alt-index git add file &&\n+\techo \"$PWD/alt-index\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n+\ttest_cmp expect-lockpath arg-two\n+'\n+\n+test_expect_success 'setup for mixed-result tests' '\n+\techo \"*.ignored\" >.gitignore &&\n+\tgit add .gitignore &&\n+\tgit commit -m \"add gitignore\"\n+'\n+\n+test_expect_success 'mixed-result add invokes pre-add hook' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n+\techo changed >>file &&\n+\techo ignored >bad.ignored &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n+\tgrep \"^file$\" proposed &&\n+\techo invoked >hook-ran &&\n+\texit 1\n+\tEOF\n+\ttest_must_fail git add file bad.ignored &&\n+\ttest_path_is_file hook-ran &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'mixed-result add stages tracked update on approve' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n+\techo changed >>file &&\n+\techo ignored >bad.ignored &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n+\tgrep \"^file$\" proposed &&\n+\techo invoked >hook-ran\n+\tEOF\n+\ttest_must_fail git add file bad.ignored &&\n+\ttest_path_is_file hook-ran &&\n+\tgit diff --cached --name-only HEAD >staged &&\n+\tgrep \"^file$\" staged &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'post-index-change fires after pre-add approval' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f hook-order expect lockfile-present\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre >>hook-order\n+\tEOF\n+\ttest_hook post-index-change <<-\\EOF &&\n+\tif test -f \".git/index.lock\"\n+\tthen\n+\t\techo locked >lockfile-present\n+\tfi\n+\techo post >>hook-order\n+\tEOF\n+\techo updated >>file &&\n+\tgit add file &&\n+\tcat >expect <<-\\EOF &&\n+\tpre\n+\tpost\n+\tEOF\n+\ttest_cmp expect hook-order &&\n+\ttest_path_is_missing lockfile-present\n+'\n+\n+test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f index.before hook-order expect\" &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre >>hook-order &&\n+\texit 1\n+\tEOF\n+\ttest_hook post-index-change <<-\\EOF &&\n+\techo post >>hook-order\n+\tEOF\n+\techo reject >>file &&\n+\ttest_must_fail git add file &&\n+\techo pre >expect &&\n+\ttest_cmp expect hook-order &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success '--dry-run does not invoke hook' '\n+\ttest_when_finished \"rm -f hook-ran dry\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\techo dry >dry &&\n+\tgit add --dry-run dry &&\n+\ttest_path_is_missing hook-ran\n+'\n+\n+test_expect_success 'hook runs for git add -u' '\n+\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\techo changed >>file &&\n+\tgit add -u &&\n+\ttest_path_is_file hook-ran\n+'\n+\n+test_expect_success 'hook example: block .env files' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f .env safe.txt new-paths\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n+\twhile read path\n+\tdo\n+\t\tcase \"$path\" in\n+\t\t*.env)\n+\t\t\techo \"error: $path must not be staged\" >&2\n+\t\t\texit 1\n+\t\t\t;;\n+\t\tesac\n+\tdone <new-paths\n+\tEOF\n+\techo \"DB_PASS=secret\" >.env &&\n+\ttest_must_fail git add .env &&\n+\techo \"safe content\" >safe.txt &&\n+\tgit add safe.txt\n+'\n+\n+test_expect_success 'hook example: block secrets in content' '\n+\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n+\tif grep -q \"API_KEY=\" secret ||\n+\t   grep -q \"SECRET_KEY=\" secret ||\n+\t   grep -q \"PRIVATE_KEY=\" secret\n+\tthen\n+\t\techo \"error: staged content contains secrets\" >&2\n+\t\texit 1\n+\tfi\n+\tEOF\n+\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n+\ttest_must_fail git add config.txt &&\n+\techo \"LOG_LEVEL=debug\" >config.txt &&\n+\tgit add config.txt\n+'\n+\n+test_done\n\nbase-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4\n-- \ngitgitgadget\n"},{"id":"537924","messageId":"bZlAfG-eGF23RvwXtNxPW4pMGpvnbN9ka2zffmvqFklilQcvMddz6N4K5zajvz3cRPeswIxWkKtXx4fk5DSA1Jq4b6teJN-6nMsAAPp4bkg=@pm.me","threadId":"64972","inReplyTo":"27ee9a9c-0caa-4b6e-a968-51c71c8b6e5f@gmail.com","subject":"[PATCH v4] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-03-05T11:40:13Z","receivedAt":"2026-03-05T11:40:27Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"\nHello all,\n\nThank you for your responses. I have incorporated all your documentation and code refactoring feedback. Excessive implementation detail commentary has been removed. A helper function has been added and the extra free removed. \n\nI have also CC'd Adrian Ratiu, who I believe led the hooks config file and parallelization additions. Please let me know if anything for this pre-add hook would need to be updated to make the most of your work, which I appreciate.\n\nChandra Kethi-Reddy\n@archonphronesis:matrix.org\n\nSent with Proton Mail secure email.\n\nOn Thursday, March 5th, 2026 at 4:20 PM, Phillip Wood <phillip.wood123@gmail.com> wrote:\n\n> On 27/02/2026 05:54, Chandra Kethi-Reddy via GitGitGadget wrote:\n> > From: Chandra Kethi-Reddy <chandrakr@pm.me>\n> >\n> > \"git add\" has no hook that lets users inspect what is about to be\n> > staged. Users who want to reject certain paths or content must\n> > wrap the command in a shell alias or wait for pre-commit, which\n> > fires too late to prevent staging.\n> >\n> > Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n> > new index state but before committing it to disk. The hook\n> > receives two positional arguments:\n> >\n> >    $1 -- index path used by this invocation (may not exist yet)\n> \n> Does this mean 'the index state before running \"git add\"'?\n> \n> >    $2 -- lockfile path containing proposed staged index state\n> >\n> > While the lockfile is active the current index path remains readable\n> > and unchanged, so a seperate copy is unnecessary. Hook authors can\n> > inspect the computed result with ordinary tools:\n> >\n> >    GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n> \n> If I understand the definition of \"$2\" above correctly this will show\n> all the paths that have been staged since the last commit, not just the\n> paths that are staged by the current invocation of \"git add\". That means\n> if for some reason I need to bypass the hook when running \"git add\" I'll\n> have to bypass it every time until I commit and cannot check the other\n> changes that I'm staging. It also means that running \"git add\" several\n> times, each with a different path runs the hook multiple times on the\n> same content.\n> \n> To get the list of paths that have changed since the last invocation of\n> \"git add\" you'd need to diff against the other index which isn't\n> possible to do directly. If there are no unmerged paths you can write a\n> tree but if there are unmerged paths \"git write-tree\" will fail and so\n> you cannot use things like \"git diff --check\" and have to fall back to\n> inspecting the changes by running \"git diff-index --cached\" on each\n> index, munging them together and feeding that into \"git diff-pairs\"\n> \n> >\n> > without needing to interpret pathspec or mode flags as the proposed\n> > index already reflects their effect.\n> >\n> > The hook is bypassed with \"--no-verify\" and is not invoked for\n> > --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n> > which stages through its own code path.\n> \n> These caveats are rather unfortunate as it means to be sure that staged\n> changes get checked I have to duplicate the \"pre-add\" checks in the\n> \"pre-commit\" hook which is rather inefficient. It would be very nice to\n> be able to check changes as they're staged rather than just before they\n> are committed but I can't help feeling that what's proposed here is\n> driven by ease of implementation which leads to a rather incoherent user\n> experience.\n> \n> Thanks\n> \n> Phillip\n> \n> >\n> > Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n> > ---\n> >      add: support pre-add hook\n> >\n> >\n> >      Summary\n> >      =======\n> >\n> >       * v3 switches pre-add inputs to stable paths ($1 index, $2 lockfile)\n> >         and removes copy-specific tempfile logic\n> >       * v3 fixes mixed-result gating so the hook runs whenever index content\n> >         changed, even if git add returned non-zero\n> >       * v3 adds SKIP_INDEX_CHANGE_HOOK flag to write_locked_index() so that\n> >         post-index-change is not fired while the lockfile is still on disk\n> >\n> >\n> >      Notes\n> >      =====\n> >\n> >       * This design intentionally trades ODB prevention for correctness of\n> >         hook inputs: blobs may already be written to object storage when the\n> >         hook runs, but hook rejection still leaves the on-disk index\n> >         unchanged\n> >       * AI Disclosure: Codex and Claude Code CLI were used to assist\n> >         drafting. All tests, code, and docs were committed by hand.\n> >\n> > Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v3\n> > Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v3\n> > Pull-Request: https://github.com/gitgitgadget/git/pull/2045\n> >\n> > Range-diff vs v2:\n> >\n> >   1:  10244150e24 ! 1:  d0fb5f9da21 add: support pre-add hook\n> >       @@ Commit message\n> >            \"git add\" has no hook that lets users inspect what is about to be\n> >            staged. Users who want to reject certain paths or content must\n> >            wrap the command in a shell alias or wait for pre-commit, which\n> >       -    fires after staging is already done and objects may already be in\n> >       -    the object database.\n> >       +    fires too late to prevent staging.\n> >\n> >            Introduce a \"pre-add\" hook that runs after \"git add\" computes the\n> >            new index state but before committing it to disk. The hook\n> >       -    receives two arguments:\n> >       +    receives two positional arguments:\n> >\n> >       -      $1 -- path to a temporary copy of the index before this \"git add\"\n> >       -      $2 -- path to the lockfile containing the proposed index\n> >       +      $1 -- index path used by this invocation (may not exist yet)\n> >       +      $2 -- lockfile path containing proposed staged index state\n> >\n> >       -    $1 on first add can be a non-existent path representing an empty\n> >       -    index.\n> >       -\n> >       -    Hook authors can inspect the computed result with ordinary tools:\n> >       +    While the lockfile is active the current index path remains readable\n> >       +    and unchanged, so a seperate copy is unnecessary. Hook authors can\n> >       +    inspect the computed result with ordinary tools:\n> >\n> >              GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n> >\n> >       -    without needing to interpret pathspec or mode flags like \"-u\" or\n> >       -    \"--renormalize\" -- the proposed index already reflects their effect.\n> >       +    without needing to interpret pathspec or mode flags as the proposed\n> >       +    index already reflects their effect.\n> >       +\n> >       +    At the finish label, write_locked_index() writes the proposed index\n> >       +    to the lockfile without COMMIT_LOCK so commit_lock_file() can be\n> >       +    called seperately after the hook runs. However, do_write_locked_index()\n> >       +    unconditionally fires post-index-change after every write, and the\n> >       +    existing test suite (t7113) asserts that index.lock does not exist when\n> >       +    that hook fires. Tying the hook to COMMIT_LOCK would suppress it for\n> >       +    other callers that depend on it after a non-committed write (e.g.,\n> >       +    prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK\n> >       +    flag lets builtin/add.c suppress the automatic notification on just this\n> >       +    call, then emit post-index-change manually after commit_lock_file()\n> >       +    publishes the new index. If the hook rejects, rollback_lock_file()\n> >       +    discards the lockfile and the original index is left unchanged. When\n> >       +    no hook is installed the existing write_locked_index(COMMIT_LOCK |\n> >       +    SKIP_IF_UNCHANGED) path is taken.\n> >\n> >       -    The implementation creates a temporary copy of the index via the\n> >       -    tempfile API when find_hook(\"pre-add\") reports a hook is present,\n> >       -    then lets all staging proceed normally. At the finish label,\n> >       -    write_locked_index() writes the proposed index to the lockfile\n> >       -    without COMMIT_LOCK. If the hook approves, commit_lock_file()\n> >       -    atomically replaces the index. If the hook rejects,\n> >       -    rollback_lock_file() discards the lockfile and the original index\n> >       -    is left unchanged. When no hook is installed, the existing\n> >       -    write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still\n> >       -    taken.\n> >       +    The hook gate checks cache_changed regardless of exit_status so that\n> >       +    mixed-result adds (e.g., a tracked modification combined with an\n> >       +    ignored path) still run the hook when index content changes.\n> >\n> >            The hook is bypassed with \"--no-verify\" and is not invoked for\n> >            --interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\n> >            which stages through its own code path.\n> >\n> >       -    Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson\n> >       -    and Makefile lists.\n> >       -\n> >            Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n> >\n> >         ## Documentation/git-add.adoc ##\n> >       @@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If yo\n> >         filename of an ignored file, `git add` will fail with a list of ignored\n> >         files. Otherwise it will silently ignore the file.\n> >\n> >       -+A pre-add hook can be run to inspect or reject the proposed index update\n> >       ++A `pre-add` hook can be run to inspect or reject the proposed index update\n> >        +after `git add` computes staging and writes it to the index lockfile,\n> >        +but before writing it to the final index. See linkgit:githooks[5].\n> >        +\n> >       @@ Documentation/git-add.adoc: for `git add --no-all <pathspec>...`, i.e. ignored r\n> >         \tinformation in the index.\n> >\n> >        +`--no-verify`::\n> >       -+\tBypass the pre-add hook if it exists. See linkgit:githooks[5] for\n> >       ++\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n> >        +\tmore information about hooks.\n> >        +\n> >         `--ignore-errors`::\n> >       @@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com\n> >        +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> >        +`--edit`, or `--dry-run`.\n> >        +\n> >       -+It takes two parameters: the path to a copy of the index before this\n> >       -+invocation of `git add`, and the path to the lockfile containing the\n> >       -+proposed index after staging. It does not read from standard input.\n> >       -+If no index exists yet, the first parameter names a path that does not\n> >       -+exist and should be treated as an empty index. No special environment\n> >       -+variables are set. The hook is invoked after the index has been updated\n> >       -+in memory and written to the lockfile, but before it is committed to the\n> >       -+final location.\n> >       ++It takes two parameters: the path to the index file for this invocation\n> >       ++of `git add`, and the path to the lockfile containing the proposed\n> >       ++index after staging. It does not read from standard input. If no index\n> >       ++exists yet, the first parameter names a path that does not exist and\n> >       ++should be treated as an empty index.\n> >       ++\n> >       ++The hook is invoked after the index has been updated in memory and\n> >       ++written to the lockfile, but before it is committed to the final index\n> >       ++path. Exiting with a non-zero status causes `git add` to reject the\n> >       ++proposed state, roll back the lockfile, and leave the index unchanged.\n> >       ++Exiting with zero status allows the index update to be committed.\n> >        +\n> >       -+Exiting with a non-zero status causes `git add` to abort and leaves the\n> >       -+index unchanged. Exiting with zero status causes the staged changes to\n> >       -+take effect.\n> >       ++Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n> >       ++set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n> >       ++`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n> >        +\n> >        +This hook can be used to prevent staging of files based on names, content,\n> >        +or sizes (e.g., to block `.env` files, secret keys, or large files).\n> >        +\n> >        +This hook is not invoked by `git commit -a` or `git commit --include`\n> >       -+which still can run the pre-commit hook, providing a control point at\n> >       ++which still can run the `pre-commit` hook, providing a control point at\n> >        +commit time.\n> >        +\n> >         pre-commit\n> >       @@ builtin/add.c\n> >         #include \"submodule.h\"\n> >         #include \"add-interactive.h\"\n> >        +#include \"hook.h\"\n> >       -+#include \"copy.h\"\n> >       ++#include \"abspath.h\"\n> >\n> >         static const char * const builtin_add_usage[] = {\n> >         \tN_(\"git add [<options>] [--] <pathspec>...\"),\n> >       @@ builtin/add.c: int cmd_add(int argc,\n> >         \tstruct lock_file lock_file = LOCK_INIT;\n> >         \tstruct odb_transaction *transaction;\n> >        +\tint run_pre_add = 0;\n> >       -+\tstruct tempfile *orig_index = NULL;\n> >        +\tchar *orig_index_path = NULL;\n> >\n> >         \trepo_config(repo, add_config, NULL);\n> >       @@ builtin/add.c: int cmd_add(int argc,\n> >         \t}\n> >\n> >        +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> >       -+\t\tint fd_in, status;\n> >       -+\t\tconst char *index_file = repo_get_index_file(repo);\n> >       -+\t\tchar *template;\n> >       -+\n> >        +\t\trun_pre_add = 1;\n> >       -+\t\ttemplate = xstrfmt(\"%s.pre-add.XXXXXX\", index_file);\n> >       -+\t\torig_index = xmks_tempfile(template);\n> >       -+\t\tfree(template);\n> >       -+\n> >       -+\t\tfd_in = open(index_file, O_RDONLY);\n> >       -+\t\tif (fd_in >= 0) {\n> >       -+\t\t\tstatus = copy_fd(fd_in, get_tempfile_fd(orig_index));\n> >       -+\t\t\tif (close(fd_in))\n> >       -+\t\t\t\tdie_errno(_(\"unable to close index for pre-add hook\"));\n> >       -+\t\t\tif (close_tempfile_gently(orig_index))\n> >       -+\t\t\t\tdie_errno(_(\"unable to close temporary index copy\"));\n> >       -+\t\t\tif (status < 0)\n> >       -+\t\t\t\tdie(_(\"failed to copy index for pre-add hook\"));\n> >       -+\t\t} else if (errno == ENOENT) {\n> >       -+\t\t\torig_index_path = xstrdup(get_tempfile_path(orig_index));\n> >       -+\t\t\tif (delete_tempfile(&orig_index))\n> >       -+\t\t\t\tdie_errno(_(\"unable to remove temporary index copy\"));\n> >       -+\t\t} else {\n> >       -+\t\t\tdie_errno(_(\"unable to open index for pre-add hook\"));\n> >       -+\t\t}\n> >       ++\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n> >        +\t}\n> >        +\n> >         \ttransaction = odb_transaction_begin(repo->objects);\n> >       @@ builtin/add.c: int cmd_add(int argc,\n> >         \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n> >        -\t    report_path_error(ps_matched, &pathspec))\n> >        +\t    report_path_error(ps_matched, &pathspec)) {\n> >       -+\t\tif (orig_index)\n> >       -+\t\t\tdelete_tempfile(&orig_index);\n> >        +\t\tfree(orig_index_path);\n> >         \t\texit(128);\n> >        +\t}\n> >       @@ builtin/add.c: int cmd_add(int argc,\n> >        -\tif (write_locked_index(repo->index, &lock_file,\n> >        -\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> >        -\t\tdie(_(\"unable to write new index file\"));\n> >       -+\tif (run_pre_add && !exit_status && repo->index->cache_changed) {\n> >       ++\tif (run_pre_add && repo->index->cache_changed) {\n> >        +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> >        +\n> >       -+\t\tif (write_locked_index(repo->index, &lock_file, 0))\n> >       -+\t\t\tdie(_(\"unable to write new index file\"));\n> >       ++\t\tif (write_locked_index(repo->index, &lock_file,\n> >       ++\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n> >       ++\t\t\tdie(_(\"unable to write proposed index\"));\n> >        +\n> >       -+\t\tstrvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :\n> >       -+\t\t\t\t\t     orig_index_path);\n> >       ++\t\tstrvec_push(&opt.args, orig_index_path);\n> >        +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n> >        +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> >        +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n> >        +\t\t\texit_status = 1;\n> >       ++\t\t} else if (commit_lock_file(&lock_file)) {\n> >       ++\t\t\tdie(_(\"unable to write new index file\"));\n> >        +\t\t} else {\n> >       -+\t\t\tif (commit_lock_file(&lock_file)) /* hook approved */\n> >       -+\t\t\t\tdie(_(\"unable to write new index file\"));\n> >       ++\t\t\trun_hooks_l(repo, \"post-index-change\",\n> >       ++\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n> >       ++\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n> >       ++\t\t\t\t    NULL);\n> >        +\t\t}\n> >       ++\t\trepo->index->updated_workdir = 0;\n> >       ++\t\trepo->index->updated_skipworktree = 0;\n> >        +\t} else {\n> >        +\t\tif (write_locked_index(repo->index, &lock_file,\n> >        +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> >        +\t\t\tdie(_(\"unable to write new index file\"));\n> >        +\t}\n> >        +\n> >       -+\tdelete_tempfile(&orig_index);\n> >        +\tfree(orig_index_path);\n> >\n> >         \tfree(ps_matched);\n> >         \tdir_clear(&dir);\n> >\n> >       + ## read-cache-ll.h ##\n> >       +@@ read-cache-ll.h: int is_index_unborn(struct index_state *);\n> >       + /* For use with `write_locked_index()`. */\n> >       + #define COMMIT_LOCK\t\t(1 << 0)\n> >       + #define SKIP_IF_UNCHANGED\t(1 << 1)\n> >       ++#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n> >       +\n> >       + /*\n> >       +  * Write the index while holding an already-taken lock. Close the lock,\n> >       +\n> >       + ## read-cache.c ##\n> >       +@@ read-cache.c: static int do_write_locked_index(struct index_state *istate,\n> >       + \telse\n> >       + \t\tret = close_lock_file_gently(lock);\n> >       +\n> >       +-\trun_hooks_l(the_repository, \"post-index-change\",\n> >       +-\t\t    istate->updated_workdir ? \"1\" : \"0\",\n> >       +-\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n> >       +-\tistate->updated_workdir = 0;\n> >       +-\tistate->updated_skipworktree = 0;\n> >       +-\n> >       ++\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n> >       ++\t\trun_hooks_l(the_repository, \"post-index-change\",\n> >       ++\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n> >       ++\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n> >       ++\t\tistate->updated_workdir = 0;\n> >       ++\t\tistate->updated_skipworktree = 0;\n> >       ++\t}\n> >       + \treturn ret;\n> >       + }\n> >       +\n> >       +\n> >         ## t/meson.build ##\n> >        @@ t/meson.build: integration_tests = [\n> >           't3703-add-magic-pathspec.sh',\n> >       @@ t/t3706-pre-add-hook.sh (new)\n> >        +\ttest_path_is_missing actual\n> >        +'\n> >        +\n> >       -+test_expect_success 'hook receives original and proposed index as arguments' '\n> >       -+\ttest_when_finished \"rm -f tracked expected hook-ran\" &&\n> >       -+\techo \"initial\" >tracked &&\n> >       -+\tgit add tracked &&\n> >       -+\tgit commit -m \"initial\" &&\n> >       ++test_expect_success 'setup for path-based tests' '\n> >       ++\tgit add file &&\n> >       ++\tgit commit -m \"initial\"\n> >       ++'\n> >       ++\n> >       ++test_expect_success 'hook receives index-path and lockfile-path arguments' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n> >       ++\t\t\t    expect-index expect-lockpath\" &&\n> >       ++\techo staged >staged &&\n> >       ++\tcat >expect-count <<-\\EOF &&\n> >       ++\t2\n> >       ++\tEOF\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >       -+\ttest $# -eq 2 &&\n> >       -+\ttest -f \"$1\" &&\n> >       -+\ttest -f \"$2\" &&\n> >       -+\techo pass >hook-ran\n> >       ++\techo \"$#\" >arg-count &&\n> >       ++\techo \"$1\" >arg-one &&\n> >       ++\techo \"$2\" >arg-two &&\n> >       ++\ttest \"$1\" != \"$2\" &&\n> >       ++\ttest -r \"$2\"\n> >        +\tEOF\n> >       ++\tgit add staged &&\n> >       ++\ttest_cmp expect-count arg-count &&\n> >       ++\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n> >       ++\ttest_cmp expect-index arg-one &&\n> >       ++\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n> >       ++\ttest_cmp expect-lockpath arg-two\n> >       ++'\n> >        +\n> >       -+\techo \"modified\" >tracked &&\n> >       -+\tgit add tracked &&\n> >       -+\techo pass >expected &&\n> >       -+\ttest_cmp expected hook-ran\n> >       ++test_expect_success 'hook rejection leaves final index unchanged' '\n> >       ++\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n> >       ++\tcp .git/index index.before &&\n> >       ++\ttest_hook pre-add <<-\\EOF &&\n> >       ++\texit 1\n> >       ++\tEOF\n> >       ++\techo reject >reject &&\n> >       ++\ttest_must_fail git add reject &&\n> >       ++\ttest_cmp_bin index.before .git/index &&\n> >       ++\ttest_path_is_missing .git/index.lock\n> >        +'\n> >        +\n> >       -+test_expect_success 'hook handles first add with no existing index' '\n> >       -+\ttest_when_finished \"rm -rf no-index\" &&\n> >       -+\ttest_create_repo no-index &&\n> >       -+\techo ok >no-index/expected &&\n> >       -+\ttest_hook -C no-index pre-add <<-\\EOF &&\n> >       -+\ttest $# -eq 2 &&\n> >       ++test_expect_success 'missing pre-existing index path treated as empty' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n> >       ++\trm -f .git/index &&\n> >       ++\ttest_hook pre-add <<-\\EOF &&\n> >       ++\techo \"$1\" >arg-one &&\n> >        +\ttest ! -e \"$1\" &&\n> >       -+\ttest -f \"$2\" &&\n> >       -+\techo ok >hook-ran\n> >       ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n> >       ++\tsort after.raw >after\n> >        +\tEOF\n> >       -+\n> >       -+\techo first >no-index/file &&\n> >       -+\tgit -C no-index add file &&\n> >       -+\ttest_cmp no-index/expected no-index/hook-ran\n> >       ++\techo newfile >newfile &&\n> >       ++\tgit add newfile &&\n> >       ++\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n> >       ++\ttest_cmp expect-index arg-one &&\n> >       ++\tgrep \"^newfile$\" after &&\n> >       ++\tgrep \"^file$\" after\n> >        +'\n> >        +\n> >       -+test_expect_success 'hook is not invoked with --dry-run (show-only)' '\n> >       -+\ttest_when_finished \"rm -f actual\" &&\n> >       ++test_expect_success 'hook respects GIT_INDEX_FILE' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n> >       ++\t\t\t    alt-index alt-index.lock\" &&\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >       -+\techo should-not-run >>actual\n> >       -+\texit 1\n> >       ++\techo \"$1\" >arg-one &&\n> >       ++\techo \"$2\" >arg-two\n> >        +\tEOF\n> >       ++\techo changed >>file &&\n> >       ++\tGIT_INDEX_FILE=alt-index git add file &&\n> >       ++\techo \"$PWD/alt-index\" >expect-index &&\n> >       ++\ttest_cmp expect-index arg-one &&\n> >       ++\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n> >       ++\ttest_cmp expect-lockpath arg-two\n> >       ++'\n> >        +\n> >       -+\techo content >file &&\n> >       -+\tgit add --dry-run file &&\n> >       -+\ttest_path_is_missing actual\n> >       ++test_expect_success 'setup for mixed-result tests' '\n> >       ++\techo \"*.ignored\" >.gitignore &&\n> >       ++\tgit add .gitignore &&\n> >       ++\tgit commit -m \"add gitignore\"\n> >        +'\n> >        +\n> >       -+test_expect_success 'hook is invoked with git add -u' '\n> >       -+\ttest_when_finished \"rm -f actual expected file\" &&\n> >       -+\techo \"initial\" >file &&\n> >       -+\tgit add file &&\n> >       -+\tgit commit -m \"initial\" &&\n> >       -+\techo \"pre-add\" >expected &&\n> >       ++test_expect_success 'mixed-result add invokes pre-add hook' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n> >       ++\techo changed >>file &&\n> >       ++\techo ignored >bad.ignored &&\n> >       ++\tcp .git/index index.before &&\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >       -+\techo pre-add >>actual\n> >       ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n> >       ++\tgrep \"^file$\" proposed &&\n> >       ++\techo invoked >hook-ran &&\n> >       ++\texit 1\n> >        +\tEOF\n> >       -+\n> >       -+\techo modified >file &&\n> >       -+\tgit add -u &&\n> >       -+\ttest_cmp expected actual\n> >       ++\ttest_must_fail git add file bad.ignored &&\n> >       ++\ttest_path_is_file hook-ran &&\n> >       ++\ttest_cmp_bin index.before .git/index &&\n> >       ++\ttest_path_is_missing .git/index.lock\n> >        +'\n> >        +\n> >       -+test_expect_success 'hook can compare original and proposed index' '\n> >       -+\ttest_when_finished \"rm -f old-raw new-raw old-list new-list \\\n> >       -+\t\t\t    expected-old expected-new\" &&\n> >       -+\techo \"initial\" >file1 &&\n> >       -+\techo \"initial\" >file2 &&\n> >       -+\tgit add file1 file2 &&\n> >       -+\tgit commit -m \"initial\" &&\n> >       -+\techo \"staged-before\" >file1 &&\n> >       -+\tgit add file1 &&\n> >       ++test_expect_success 'mixed-result add stages tracked update on approve' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n> >       ++\techo changed >>file &&\n> >       ++\techo ignored >bad.ignored &&\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >       -+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n> >       -+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n> >       -+\tsort old-raw >old-list &&\n> >       -+\tsort new-raw >new-list\n> >       ++\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n> >       ++\tgrep \"^file$\" proposed &&\n> >       ++\techo invoked >hook-ran\n> >        +\tEOF\n> >       -+\n> >       -+\techo \"modified\" >file2 &&\n> >       -+\tgit add file2 &&\n> >       -+\techo file1 >expected-old &&\n> >       -+\tprintf \"%s\\n\" file1 file2 >expected-new &&\n> >       -+\ttest_cmp expected-old old-list &&\n> >       -+\ttest_cmp expected-new new-list\n> >       ++\ttest_must_fail git add file bad.ignored &&\n> >       ++\ttest_path_is_file hook-ran &&\n> >       ++\tgit diff --cached --name-only HEAD >staged &&\n> >       ++\tgrep \"^file$\" staged &&\n> >       ++\ttest_path_is_missing .git/index.lock\n> >        +'\n> >        +\n> >       -+test_expect_success 'hook rejection rolls back index unchanged' '\n> >       -+\ttest_when_finished \"rm -f file before after old-raw new-raw \\\n> >       -+\t\t\t    old-list new-list expected-old expected-new\" &&\n> >       -+\techo \"initial\" >file &&\n> >       ++test_expect_success 'post-index-change fires after pre-add approval' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f hook-order expect lockfile-present\" &&\n> >       ++\ttest_hook pre-add <<-\\EOF &&\n> >       ++\techo pre >>hook-order\n> >       ++\tEOF\n> >       ++\ttest_hook post-index-change <<-\\EOF &&\n> >       ++\tif test -f \".git/index.lock\"\n> >       ++\tthen\n> >       ++\t\techo locked >lockfile-present\n> >       ++\tfi\n> >       ++\techo post >>hook-order\n> >       ++\tEOF\n> >       ++\techo updated >>file &&\n> >        +\tgit add file &&\n> >       -+\tgit commit -m \"initial\" &&\n> >       -+\tgit diff --cached --name-only HEAD >before &&\n> >       ++\tcat >expect <<-\\EOF &&\n> >       ++\tpre\n> >       ++\tpost\n> >       ++\tEOF\n> >       ++\ttest_cmp expect hook-order &&\n> >       ++\ttest_path_is_missing lockfile-present\n> >       ++'\n> >       ++\n> >       ++test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f index.before hook-order expect\" &&\n> >       ++\tcp .git/index index.before &&\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >       -+\tGIT_INDEX_FILE=\"$1\" git diff --cached --name-only HEAD >old-raw &&\n> >       -+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-raw &&\n> >       -+\tsort old-raw >old-list &&\n> >       -+\tsort new-raw >new-list &&\n> >       ++\techo pre >>hook-order &&\n> >        +\texit 1\n> >        +\tEOF\n> >       -+\n> >       -+\techo \"modified\" >file &&\n> >       ++\ttest_hook post-index-change <<-\\EOF &&\n> >       ++\techo post >>hook-order\n> >       ++\tEOF\n> >       ++\techo reject >>file &&\n> >        +\ttest_must_fail git add file &&\n> >       -+\tgit diff --cached --name-only HEAD >after &&\n> >       -+\ttest_cmp before after &&\n> >       -+\t: >expected-old &&\n> >       -+\techo file >expected-new &&\n> >       -+\ttest_cmp expected-old old-list &&\n> >       -+\ttest_cmp expected-new new-list\n> >       ++\techo pre >expect &&\n> >       ++\ttest_cmp expect hook-order &&\n> >       ++\ttest_cmp_bin index.before .git/index &&\n> >       ++\ttest_path_is_missing .git/index.lock\n> >       ++'\n> >       ++\n> >       ++test_expect_success '--dry-run does not invoke hook' '\n> >       ++\ttest_when_finished \"rm -f hook-ran dry\" &&\n> >       ++\ttest_hook pre-add <<-\\EOF &&\n> >       ++\techo invoked >hook-ran\n> >       ++\tEOF\n> >       ++\techo dry >dry &&\n> >       ++\tgit add --dry-run dry &&\n> >       ++\ttest_path_is_missing hook-ran\n> >       ++'\n> >       ++\n> >       ++test_expect_success 'hook runs for git add -u' '\n> >       ++\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n> >       ++\ttest_hook pre-add <<-\\EOF &&\n> >       ++\techo invoked >hook-ran\n> >       ++\tEOF\n> >       ++\techo changed >>file &&\n> >       ++\tgit add -u &&\n> >       ++\ttest_path_is_file hook-ran\n> >        +'\n> >        +\n> >        +test_expect_success 'hook example: block .env files' '\n> >       -+\ttest_when_finished \"rm -f .env safe.txt new-paths\" &&\n> >       -+\techo \"initial\" >base &&\n> >       -+\tgit add base &&\n> >       -+\tgit commit -m \"initial\" &&\n> >       ++\ttest_when_finished \"git reset --hard &&\n> >       ++\t\t\t    rm -f .env safe.txt new-paths\" &&\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >        +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n> >        +\twhile read path\n> >        +\tdo\n> >        +\t\tcase \"$path\" in\n> >       -+\t\t*.env|.env)\n> >       ++\t\t*.env)\n> >        +\t\t\techo \"error: $path must not be staged\" >&2\n> >        +\t\t\texit 1\n> >        +\t\t\t;;\n> >        +\t\tesac\n> >        +\tdone <new-paths\n> >        +\tEOF\n> >       -+\n> >        +\techo \"DB_PASS=secret\" >.env &&\n> >        +\ttest_must_fail git add .env &&\n> >        +\techo \"safe content\" >safe.txt &&\n> >       @@ t/t3706-pre-add-hook.sh (new)\n> >        +'\n> >        +\n> >        +test_expect_success 'hook example: block secrets in content' '\n> >       -+\ttest_when_finished \"rm -f config.txt secret\" &&\n> >       -+\techo \"initial\" >config.txt &&\n> >       -+\tgit add config.txt &&\n> >       -+\tgit commit -m \"initial\" &&\n> >       ++\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n> >        +\ttest_hook pre-add <<-\\EOF &&\n> >        +\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n> >       -+\tif grep -qE \"(API_KEY|SECRET_KEY|PRIVATE_KEY)=\" secret\n> >       ++\tif grep -q \"API_KEY=\" secret ||\n> >       ++\t   grep -q \"SECRET_KEY=\" secret ||\n> >       ++\t   grep -q \"PRIVATE_KEY=\" secret\n> >        +\tthen\n> >        +\t\techo \"error: staged content contains secrets\" >&2\n> >        +\t\texit 1\n> >        +\tfi\n> >        +\tEOF\n> >       -+\n> >        +\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n> >        +\ttest_must_fail git add config.txt &&\n> >        +\techo \"LOG_LEVEL=debug\" >config.txt &&\n> >\n> >\n> >   Documentation/git-add.adoc  |  11 +-\n> >   Documentation/githooks.adoc |  30 ++++\n> >   builtin/add.c               |  47 +++++-\n> >   read-cache-ll.h             |   1 +\n> >   read-cache.c                |  13 +-\n> >   t/meson.build               |   1 +\n> >   t/t3706-pre-add-hook.sh     | 289 ++++++++++++++++++++++++++++++++++++\n> >   7 files changed, 381 insertions(+), 11 deletions(-)\n> >   create mode 100755 t/t3706-pre-add-hook.sh\n> >\n> > diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\n> > index 6192daeb03..b47751acca 100644\n> > --- a/Documentation/git-add.adoc\n> > +++ b/Documentation/git-add.adoc\n> > @@ -10,7 +10,7 @@ SYNOPSIS\n> >   [synopsis]\n> >   git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n> >   \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n> > -\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n> > +\t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]\n> >   \t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n> >   \t[--] [<pathspec>...]\n> >\n> > @@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact\n> >   filename of an ignored file, `git add` will fail with a list of ignored\n> >   files. Otherwise it will silently ignore the file.\n> >\n> > +A `pre-add` hook can be run to inspect or reject the proposed index update\n> > +after `git add` computes staging and writes it to the index lockfile,\n> > +but before writing it to the final index. See linkgit:githooks[5].\n> > +\n> >   Please see linkgit:git-commit[1] for alternative ways to add content to a\n> >   commit.\n> >\n> > @@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n> >   \tDon't add the file(s), but only refresh their stat()\n> >   \tinformation in the index.\n> >\n> > +`--no-verify`::\n> > +\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n> > +\tmore information about hooks.\n> > +\n> >   `--ignore-errors`::\n> >   \tIf some files could not be added because of errors indexing\n> >   \tthem, do not abort the operation, but continue adding the\n> > @@ -451,6 +459,7 @@ linkgit:git-reset[1]\n> >   linkgit:git-mv[1]\n> >   linkgit:git-commit[1]\n> >   linkgit:git-update-index[1]\n> > +linkgit:githooks[5]\n> >\n> >   GIT\n> >   ---\n> > diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> > index 056553788d..657e14d306 100644\n> > --- a/Documentation/githooks.adoc\n> > +++ b/Documentation/githooks.adoc\n> > @@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n> >   This hook is meant primarily for notification, and cannot affect\n> >   the outcome of `git am`.\n> >\n> > +pre-add\n> > +~~~~~~~\n> > +\n> > +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n> > +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> > +`--edit`, or `--dry-run`.\n> > +\n> > +It takes two parameters: the path to the index file for this invocation\n> > +of `git add`, and the path to the lockfile containing the proposed\n> > +index after staging. It does not read from standard input. If no index\n> > +exists yet, the first parameter names a path that does not exist and\n> > +should be treated as an empty index.\n> > +\n> > +The hook is invoked after the index has been updated in memory and\n> > +written to the lockfile, but before it is committed to the final index\n> > +path. Exiting with a non-zero status causes `git add` to reject the\n> > +proposed state, roll back the lockfile, and leave the index unchanged.\n> > +Exiting with zero status allows the index update to be committed.\n> > +\n> > +Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may\n> > +set `GIT_INDEX_FILE=\"$1\"` to inspect current index state and\n> > +`GIT_INDEX_FILE=\"$2\"` to inspect proposed index state.\n> > +\n> > +This hook can be used to prevent staging of files based on names, content,\n> > +or sizes (e.g., to block `.env` files, secret keys, or large files).\n> > +\n> > +This hook is not invoked by `git commit -a` or `git commit --include`\n> > +which still can run the `pre-commit` hook, providing a control point at\n> > +commit time.\n> > +\n> >   pre-commit\n> >   ~~~~~~~~~~\n> >\n> > diff --git a/builtin/add.c b/builtin/add.c\n> > index 32709794b3..d4d004a35b 100644\n> > --- a/builtin/add.c\n> > +++ b/builtin/add.c\n> > @@ -25,6 +25,8 @@\n> >   #include \"strvec.h\"\n> >   #include \"submodule.h\"\n> >   #include \"add-interactive.h\"\n> > +#include \"hook.h\"\n> > +#include \"abspath.h\"\n> >\n> >   static const char * const builtin_add_usage[] = {\n> >   \tN_(\"git add [<options>] [--] <pathspec>...\"),\n> > @@ -36,6 +38,7 @@ static int take_worktree_changes;\n> >   static int add_renormalize;\n> >   static int pathspec_file_nul;\n> >   static int include_sparse;\n> > +static int no_verify;\n> >   static const char *pathspec_from_file;\n> >\n> >   static int chmod_pathspec(struct repository *repo,\n> > @@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {\n> >   \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n> >   \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n> >   \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n> > +\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n> >   \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n> >   \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n> >   \t\t   N_(\"override the executable bit of the listed files\")),\n> > @@ -391,6 +395,8 @@ int cmd_add(int argc,\n> >   \tchar *ps_matched = NULL;\n> >   \tstruct lock_file lock_file = LOCK_INIT;\n> >   \tstruct odb_transaction *transaction;\n> > +\tint run_pre_add = 0;\n> > +\tchar *orig_index_path = NULL;\n> >\n> >   \trepo_config(repo, add_config, NULL);\n> >\n> > @@ -576,6 +582,11 @@ int cmd_add(int argc,\n> >   \t\tstring_list_clear(&only_match_skip_worktree, 0);\n> >   \t}\n> >\n> > +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> > +\t\trun_pre_add = 1;\n> > +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n> > +\t}\n> > +\n> >   \ttransaction = odb_transaction_begin(repo->objects);\n> >\n> >   \tps_matched = xcalloc(pathspec.nr, 1);\n> > @@ -587,8 +598,10 @@ int cmd_add(int argc,\n> >   \t\t\t\t\t\t  include_sparse, flags);\n> >\n> >   \tif (take_worktree_changes && !add_renormalize && !ignore_add_errors &&\n> > -\t    report_path_error(ps_matched, &pathspec))\n> > +\t    report_path_error(ps_matched, &pathspec)) {\n> > +\t\tfree(orig_index_path);\n> >   \t\texit(128);\n> > +\t}\n> >\n> >   \tif (add_new_files)\n> >   \t\texit_status |= add_files(repo, &dir, flags);\n> > @@ -598,9 +611,35 @@ int cmd_add(int argc,\n> >   \todb_transaction_commit(transaction);\n> >\n> >   finish:\n> > -\tif (write_locked_index(repo->index, &lock_file,\n> > -\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> > -\t\tdie(_(\"unable to write new index file\"));\n> > +\tif (run_pre_add && repo->index->cache_changed) {\n> > +\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n> > +\n> > +\t\tif (write_locked_index(repo->index, &lock_file,\n> > +\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n> > +\t\t\tdie(_(\"unable to write proposed index\"));\n> > +\n> > +\t\tstrvec_push(&opt.args, orig_index_path);\n> > +\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n> > +\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n> > +\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n> > +\t\t\texit_status = 1;\n> > +\t\t} else if (commit_lock_file(&lock_file)) {\n> > +\t\t\tdie(_(\"unable to write new index file\"));\n> > +\t\t} else {\n> > +\t\t\trun_hooks_l(repo, \"post-index-change\",\n> > +\t\t\t\t    repo->index->updated_workdir ? \"1\" : \"0\",\n> > +\t\t\t\t    repo->index->updated_skipworktree ? \"1\" : \"0\",\n> > +\t\t\t\t    NULL);\n> > +\t\t}\n> > +\t\trepo->index->updated_workdir = 0;\n> > +\t\trepo->index->updated_skipworktree = 0;\n> > +\t} else {\n> > +\t\tif (write_locked_index(repo->index, &lock_file,\n> > +\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n> > +\t\t\tdie(_(\"unable to write new index file\"));\n> > +\t}\n> > +\n> > +\tfree(orig_index_path);\n> >\n> >   \tfree(ps_matched);\n> >   \tdir_clear(&dir);\n> > diff --git a/read-cache-ll.h b/read-cache-ll.h\n> > index 71b49d9af4..a43971c07e 100644\n> > --- a/read-cache-ll.h\n> > +++ b/read-cache-ll.h\n> > @@ -284,6 +284,7 @@ int is_index_unborn(struct index_state *);\n> >   /* For use with `write_locked_index()`. */\n> >   #define COMMIT_LOCK\t\t(1 << 0)\n> >   #define SKIP_IF_UNCHANGED\t(1 << 1)\n> > +#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n> >\n> >   /*\n> >    * Write the index while holding an already-taken lock. Close the lock,\n> > diff --git a/read-cache.c b/read-cache.c\n> > index 0c07c3aef7..5051cda4ce 100644\n> > --- a/read-cache.c\n> > +++ b/read-cache.c\n> > @@ -3161,12 +3161,13 @@ static int do_write_locked_index(struct index_state *istate,\n> >   \telse\n> >   \t\tret = close_lock_file_gently(lock);\n> >\n> > -\trun_hooks_l(the_repository, \"post-index-change\",\n> > -\t\t    istate->updated_workdir ? \"1\" : \"0\",\n> > -\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n> > -\tistate->updated_workdir = 0;\n> > -\tistate->updated_skipworktree = 0;\n> > -\n> > +\tif (!(flags & SKIP_INDEX_CHANGE_HOOK)) {\n> > +\t\trun_hooks_l(the_repository, \"post-index-change\",\n> > +\t\t\t    istate->updated_workdir ? \"1\" : \"0\",\n> > +\t\t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n> > +\t\tistate->updated_workdir = 0;\n> > +\t\tistate->updated_skipworktree = 0;\n> > +\t}\n> >   \treturn ret;\n> >   }\n> >\n> > diff --git a/t/meson.build b/t/meson.build\n> > index f80e366cff..2419a9adbb 100644\n> > --- a/t/meson.build\n> > +++ b/t/meson.build\n> > @@ -415,6 +415,7 @@ integration_tests = [\n> >     't3703-add-magic-pathspec.sh',\n> >     't3704-add-pathspec-file.sh',\n> >     't3705-add-sparse-checkout.sh',\n> > +  't3706-pre-add-hook.sh',\n> >     't3800-mktag.sh',\n> >     't3900-i18n-commit.sh',\n> >     't3901-i18n-patch.sh',\n> > diff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\n> > new file mode 100755\n> > index 0000000000..f5092f0727\n> > --- /dev/null\n> > +++ b/t/t3706-pre-add-hook.sh\n> > @@ -0,0 +1,289 @@\n> > +#!/bin/sh\n> > +\n> > +test_description='pre-add hook tests\n> > +\n> > +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).'\n> > +\n> > +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n> > +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n> > +\n> > +. ./test-lib.sh\n> > +\n> > +test_expect_success 'with no hook' '\n> > +\ttest_when_finished \"rm -f actual\" &&\n> > +\techo content >file &&\n> > +\tgit add file &&\n> > +\ttest_path_is_missing actual\n> > +'\n> > +\n> > +test_expect_success POSIXPERM 'with non-executable hook' '\n> > +\ttest_when_finished \"rm -f actual\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo should-not-run >>actual\n> > +\texit 1\n> > +\tEOF\n> > +\tchmod -x .git/hooks/pre-add &&\n> > +\n> > +\techo content >file &&\n> > +\tgit add file &&\n> > +\ttest_path_is_missing actual\n> > +'\n> > +\n> > +test_expect_success '--no-verify with no hook' '\n> > +\techo content >file &&\n> > +\tgit add --no-verify file &&\n> > +\ttest_path_is_missing actual\n> > +'\n> > +\n> > +test_expect_success 'with succeeding hook' '\n> > +\ttest_when_finished \"rm -f actual expected\" &&\n> > +\techo \"pre-add\" >expected &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo pre-add >>actual\n> > +\tEOF\n> > +\n> > +\techo content >file &&\n> > +\tgit add file &&\n> > +\ttest_cmp expected actual\n> > +'\n> > +\n> > +test_expect_success 'with failing hook' '\n> > +\ttest_when_finished \"rm -f actual\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo pre-add-rejected >>actual\n> > +\texit 1\n> > +\tEOF\n> > +\n> > +\techo content >file &&\n> > +\ttest_must_fail git add file\n> > +'\n> > +\n> > +test_expect_success '--no-verify with failing hook' '\n> > +\ttest_when_finished \"rm -f actual\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo should-not-run >>actual\n> > +\texit 1\n> > +\tEOF\n> > +\n> > +\techo content >file &&\n> > +\tgit add --no-verify file &&\n> > +\ttest_path_is_missing actual\n> > +'\n> > +\n> > +test_expect_success 'setup for path-based tests' '\n> > +\tgit add file &&\n> > +\tgit commit -m \"initial\"\n> > +'\n> > +\n> > +test_expect_success 'hook receives index-path and lockfile-path arguments' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n> > +\t\t\t    expect-index expect-lockpath\" &&\n> > +\techo staged >staged &&\n> > +\tcat >expect-count <<-\\EOF &&\n> > +\t2\n> > +\tEOF\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo \"$#\" >arg-count &&\n> > +\techo \"$1\" >arg-one &&\n> > +\techo \"$2\" >arg-two &&\n> > +\ttest \"$1\" != \"$2\" &&\n> > +\ttest -r \"$2\"\n> > +\tEOF\n> > +\tgit add staged &&\n> > +\ttest_cmp expect-count arg-count &&\n> > +\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n> > +\ttest_cmp expect-index arg-one &&\n> > +\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n> > +\ttest_cmp expect-lockpath arg-two\n> > +'\n> > +\n> > +test_expect_success 'hook rejection leaves final index unchanged' '\n> > +\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n> > +\tcp .git/index index.before &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\texit 1\n> > +\tEOF\n> > +\techo reject >reject &&\n> > +\ttest_must_fail git add reject &&\n> > +\ttest_cmp_bin index.before .git/index &&\n> > +\ttest_path_is_missing .git/index.lock\n> > +'\n> > +\n> > +test_expect_success 'missing pre-existing index path treated as empty' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n> > +\trm -f .git/index &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo \"$1\" >arg-one &&\n> > +\ttest ! -e \"$1\" &&\n> > +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n> > +\tsort after.raw >after\n> > +\tEOF\n> > +\techo newfile >newfile &&\n> > +\tgit add newfile &&\n> > +\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n> > +\ttest_cmp expect-index arg-one &&\n> > +\tgrep \"^newfile$\" after &&\n> > +\tgrep \"^file$\" after\n> > +'\n> > +\n> > +test_expect_success 'hook respects GIT_INDEX_FILE' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n> > +\t\t\t    alt-index alt-index.lock\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo \"$1\" >arg-one &&\n> > +\techo \"$2\" >arg-two\n> > +\tEOF\n> > +\techo changed >>file &&\n> > +\tGIT_INDEX_FILE=alt-index git add file &&\n> > +\techo \"$PWD/alt-index\" >expect-index &&\n> > +\ttest_cmp expect-index arg-one &&\n> > +\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n> > +\ttest_cmp expect-lockpath arg-two\n> > +'\n> > +\n> > +test_expect_success 'setup for mixed-result tests' '\n> > +\techo \"*.ignored\" >.gitignore &&\n> > +\tgit add .gitignore &&\n> > +\tgit commit -m \"add gitignore\"\n> > +'\n> > +\n> > +test_expect_success 'mixed-result add invokes pre-add hook' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n> > +\techo changed >>file &&\n> > +\techo ignored >bad.ignored &&\n> > +\tcp .git/index index.before &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n> > +\tgrep \"^file$\" proposed &&\n> > +\techo invoked >hook-ran &&\n> > +\texit 1\n> > +\tEOF\n> > +\ttest_must_fail git add file bad.ignored &&\n> > +\ttest_path_is_file hook-ran &&\n> > +\ttest_cmp_bin index.before .git/index &&\n> > +\ttest_path_is_missing .git/index.lock\n> > +'\n> > +\n> > +test_expect_success 'mixed-result add stages tracked update on approve' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n> > +\techo changed >>file &&\n> > +\techo ignored >bad.ignored &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n> > +\tgrep \"^file$\" proposed &&\n> > +\techo invoked >hook-ran\n> > +\tEOF\n> > +\ttest_must_fail git add file bad.ignored &&\n> > +\ttest_path_is_file hook-ran &&\n> > +\tgit diff --cached --name-only HEAD >staged &&\n> > +\tgrep \"^file$\" staged &&\n> > +\ttest_path_is_missing .git/index.lock\n> > +'\n> > +\n> > +test_expect_success 'post-index-change fires after pre-add approval' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f hook-order expect lockfile-present\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo pre >>hook-order\n> > +\tEOF\n> > +\ttest_hook post-index-change <<-\\EOF &&\n> > +\tif test -f \".git/index.lock\"\n> > +\tthen\n> > +\t\techo locked >lockfile-present\n> > +\tfi\n> > +\techo post >>hook-order\n> > +\tEOF\n> > +\techo updated >>file &&\n> > +\tgit add file &&\n> > +\tcat >expect <<-\\EOF &&\n> > +\tpre\n> > +\tpost\n> > +\tEOF\n> > +\ttest_cmp expect hook-order &&\n> > +\ttest_path_is_missing lockfile-present\n> > +'\n> > +\n> > +test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f index.before hook-order expect\" &&\n> > +\tcp .git/index index.before &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo pre >>hook-order &&\n> > +\texit 1\n> > +\tEOF\n> > +\ttest_hook post-index-change <<-\\EOF &&\n> > +\techo post >>hook-order\n> > +\tEOF\n> > +\techo reject >>file &&\n> > +\ttest_must_fail git add file &&\n> > +\techo pre >expect &&\n> > +\ttest_cmp expect hook-order &&\n> > +\ttest_cmp_bin index.before .git/index &&\n> > +\ttest_path_is_missing .git/index.lock\n> > +'\n> > +\n> > +test_expect_success '--dry-run does not invoke hook' '\n> > +\ttest_when_finished \"rm -f hook-ran dry\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo invoked >hook-ran\n> > +\tEOF\n> > +\techo dry >dry &&\n> > +\tgit add --dry-run dry &&\n> > +\ttest_path_is_missing hook-ran\n> > +'\n> > +\n> > +test_expect_success 'hook runs for git add -u' '\n> > +\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\techo invoked >hook-ran\n> > +\tEOF\n> > +\techo changed >>file &&\n> > +\tgit add -u &&\n> > +\ttest_path_is_file hook-ran\n> > +'\n> > +\n> > +test_expect_success 'hook example: block .env files' '\n> > +\ttest_when_finished \"git reset --hard &&\n> > +\t\t\t    rm -f .env safe.txt new-paths\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n> > +\twhile read path\n> > +\tdo\n> > +\t\tcase \"$path\" in\n> > +\t\t*.env)\n> > +\t\t\techo \"error: $path must not be staged\" >&2\n> > +\t\t\texit 1\n> > +\t\t\t;;\n> > +\t\tesac\n> > +\tdone <new-paths\n> > +\tEOF\n> > +\techo \"DB_PASS=secret\" >.env &&\n> > +\ttest_must_fail git add .env &&\n> > +\techo \"safe content\" >safe.txt &&\n> > +\tgit add safe.txt\n> > +'\n> > +\n> > +test_expect_success 'hook example: block secrets in content' '\n> > +\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n> > +\ttest_hook pre-add <<-\\EOF &&\n> > +\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n> > +\tif grep -q \"API_KEY=\" secret ||\n> > +\t   grep -q \"SECRET_KEY=\" secret ||\n> > +\t   grep -q \"PRIVATE_KEY=\" secret\n> > +\tthen\n> > +\t\techo \"error: staged content contains secrets\" >&2\n> > +\t\texit 1\n> > +\tfi\n> > +\tEOF\n> > +\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n> > +\ttest_must_fail git add config.txt &&\n> > +\techo \"LOG_LEVEL=debug\" >config.txt &&\n> > +\tgit add config.txt\n> > +'\n> > +\n> > +test_done\n> >\n> > base-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4\n> \n> \n> \n"},{"id":"537925","messageId":"87seaexz33.fsf@gentoo.mail-host-address-is-not-set","threadId":"64972","inReplyTo":"pull.2045.v4.git.1772710566599.gitgitgadget@gmail.com","subject":"Re: [PATCH v4] add: support pre-add hook","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-05T12:03:44Z","receivedAt":"2026-03-05T12:03:58Z","isPatch":true,"sender":{"key":"adrian.ratiu@collabora.com","avatar":"https://avatars.githubusercontent.com/u/12472556?v=4"},"body":"Hi Chandra,\n\nOn Thu, 05 Mar 2026, \"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com> wrote:\n> @@ -576,6 +582,11 @@ int cmd_add(int argc,\n>  \t\tstring_list_clear(&only_match_skip_worktree, 0);\n>  \t}\n>  \n> +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> +\t\trun_pre_add = 1;\n> +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n> +\t}\n> +\n\nPlease use hook_exists() instead of find_hook() because that works with\nhooks defined via config files. Otherwise your hooks API usage is great.\n\nMaybe add a test or two which define the pre-add hook via configs to\nverify it works?\n\n(regarding find_hook(), we sholud mark it as deprecated or convert all\nits remaining uses and remove it, however that's outside the scope of\nyour series, no worries)\n"},{"id":"537928","messageId":"pull.2045.v5.git.1772714253412.gitgitgadget@gmail.com","threadId":"64972","inReplyTo":"pull.2045.v4.git.1772710566599.gitgitgadget@gmail.com","subject":"[PATCH v5] add: support pre-add hook","fromName":"Chandra Kethi-Reddy via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-03-05T12:37:33Z","receivedAt":"2026-03-05T12:37:36Z","isPatch":true,"sender":{"key":"name:Chandra Kethi-Reddy","avatar":null},"body":"From: Chandra Kethi-Reddy <chandrakr@pm.me>\n\n\"git add\" has no hook that lets users inspect what is about to be\nstaged. Users who want to reject certain paths or content must\nwrap the command in a shell alias or wait for pre-commit, which\nfires too late to prevent staging.\n\nIntroduce a \"pre-add\" hook so that users can inspect or reject\nproposed index updates at staging time.\n\n  $1 -- index path used by this invocation (may not exist yet)\n  $2 -- lockfile path containing proposed staged index state\n\nHook authors can inspect the result with ordinary Git commands:\n\n  GIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD\n\nBoth files should be treated as read-only. Exiting with non-zero\nstatus rejects the update and leaves the index unchanged.\n\nThe hook accepts or rejects the entire proposed update. Per-path\nfiltering is not supported.\n\nThe hook is bypassed with \"--no-verify\" and is not invoked for\n--interactive, --patch, --edit, or --dry-run, nor by \"git commit -a\"\nwhich stages through its own code path.\n\nSigned-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>\n---\n    add: support pre-add hook\n    \n    \n    Summary\n    =======\n    \n     * v5 switches from find_hook() to hook_exists() for early hook\n       detection so hooks configured via core.hooksPath are discovered\n     * Add a tests exercises config-based hook discovery\n     * Fixed Windows CI failures with correct path formatting in the\n       relevant test\n    \n    \n    Notes\n    =====\n    \n     * This design intentionally trades ODB prevention for correctness of\n       hook inputs: blobs may already be written to object storage when the\n       hook runs, but hook rejection still leaves the on-disk index\n       unchanged\n     * AI Disclosure: Codex and Claude Code CLI were used to assist\n       drafting. All tests, code, and docs were committed by hand.\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v5\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v5\nPull-Request: https://github.com/gitgitgadget/git/pull/2045\n\nRange-diff vs v4:\n\n 1:  9383395bb0 ! 1:  fc58c4cba2 add: support pre-add hook\n     @@ builtin/add.c: int cmd_add(int argc,\n       \t\tstring_list_clear(&only_match_skip_worktree, 0);\n       \t}\n       \n     -+\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n     ++\tif (!show_only && !no_verify && hook_exists(repo, \"pre-add\")) {\n      +\t\trun_pre_add = 1;\n      +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n      +\t}\n     @@ t/t3706-pre-add-hook.sh (new)\n      +\tgit commit -m \"initial\"\n      +'\n      +\n     ++test_expect_success 'hook found via core.hooksPath' '\n     ++\ttest_when_finished \"git reset --hard &&\n     ++\t\t\t    rm -rf custom-hooks &&\n     ++\t\t\t    git config --unset core.hooksPath\" &&\n     ++\tmkdir custom-hooks &&\n     ++\twrite_script custom-hooks/pre-add <<-\\EOF &&\n     ++\techo invoked >hook-ran\n     ++\tEOF\n     ++\tgit config core.hooksPath custom-hooks &&\n     ++\techo changed >>file &&\n     ++\tgit add file &&\n     ++\ttest_path_is_file hook-ran &&\n     ++\trm -f hook-ran\n     ++'\n     ++\n      +test_expect_success 'hook receives index-path and lockfile-path arguments' '\n      +\ttest_when_finished \"git reset --hard &&\n      +\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n     @@ t/t3706-pre-add-hook.sh (new)\n      +\tEOF\n      +\techo changed >>file &&\n      +\tGIT_INDEX_FILE=alt-index git add file &&\n     -+\techo \"$PWD/alt-index\" >expect-index &&\n     ++\ttest-tool path-utils absolute_path alt-index >expect-index &&\n      +\ttest_cmp expect-index arg-one &&\n     -+\techo \"$PWD/alt-index.lock\" >expect-lockpath &&\n     ++\ttest-tool path-utils absolute_path alt-index.lock >expect-lockpath &&\n      +\ttest_cmp expect-lockpath arg-two\n      +'\n      +\n\n\n Documentation/git-add.adoc  |  10 +-\n Documentation/githooks.adoc |  30 ++++\n builtin/add.c               |  38 ++++-\n read-cache-ll.h             |   3 +\n read-cache.c                |   9 +-\n t/meson.build               |   1 +\n t/t3706-pre-add-hook.sh     | 304 ++++++++++++++++++++++++++++++++++++\n 7 files changed, 388 insertions(+), 7 deletions(-)\n create mode 100755 t/t3706-pre-add-hook.sh\n\ndiff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\nindex 6192daeb03..a3ff4ced83 100644\n--- a/Documentation/git-add.adoc\n+++ b/Documentation/git-add.adoc\n@@ -11,7 +11,7 @@ SYNOPSIS\n git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]\n \t[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]\n \t[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]\n-\t[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n+\t[--no-verify] [--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n \t[--] [<pathspec>...]\n \n DESCRIPTION\n@@ -42,10 +42,11 @@ use the `--force` option to add ignored files. If you specify the exact\n filename of an ignored file, `git add` will fail with a list of ignored\n files. Otherwise it will silently ignore the file.\n \n+A `pre-add` hook can be used to reject `git add` (see linkgit:githooks[5]).\n+\n Please see linkgit:git-commit[1] for alternative ways to add content to a\n commit.\n \n-\n OPTIONS\n -------\n `<pathspec>...`::\n@@ -163,6 +164,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.\n \tDon't add the file(s), but only refresh their stat()\n \tinformation in the index.\n \n+`--no-verify`::\n+\tBypass the `pre-add` hook if it exists. See linkgit:githooks[5] for\n+\tmore information about hooks.\n+\n `--ignore-errors`::\n \tIf some files could not be added because of errors indexing\n \tthem, do not abort the operation, but continue adding the\n@@ -451,6 +456,7 @@ linkgit:git-reset[1]\n linkgit:git-mv[1]\n linkgit:git-commit[1]\n linkgit:git-update-index[1]\n+linkgit:githooks[5]\n \n GIT\n ---\ndiff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\nindex 056553788d..90945a590e 100644\n--- a/Documentation/githooks.adoc\n+++ b/Documentation/githooks.adoc\n@@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n This hook is meant primarily for notification, and cannot affect\n the outcome of `git am`.\n \n+pre-add\n+~~~~~~~\n+\n+This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n+`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n+`--edit`, or `--dry-run`.\n+\n+It takes two arguments: the path to the index file for this invocation\n+of `git add`, and the path to the lockfile containing the proposed\n+index after staging. If no index exists yet, the first argument names\n+a path that does not exist and should be treated as an empty index.\n+\n+The hook is invoked after the index has been updated in memory and\n+written to the lockfile, but before it is committed to the final index\n+path. Exiting with a non-zero status causes `git add` to reject the\n+proposed state, roll back the lockfile, and leave the index unchanged.\n+Exiting with zero status allows the index update to be committed. The\n+hook accepts or rejects the entire proposed update; per-path filtering\n+is not supported. Both files should be treated as read-only by the hook.\n+\n+Hook authors may set `GIT_INDEX_FILE=\"$1\"` to inspect the current index\n+state and `GIT_INDEX_FILE=\"$2\"` to inspect the proposed index state.\n+\n+This hook can be used to prevent staging of files based on names, content,\n+or sizes (e.g., to block `.env` files, secret keys, or large files).\n+\n+This hook is not invoked by `git commit -a` or `git commit --include`\n+which still can run the `pre-commit` hook, providing a control point at\n+commit time.\n+\n pre-commit\n ~~~~~~~~~~\n \ndiff --git a/builtin/add.c b/builtin/add.c\nindex 32709794b3..f35994ff0f 100644\n--- a/builtin/add.c\n+++ b/builtin/add.c\n@@ -25,6 +25,8 @@\n #include \"strvec.h\"\n #include \"submodule.h\"\n #include \"add-interactive.h\"\n+#include \"hook.h\"\n+#include \"abspath.h\"\n \n static const char * const builtin_add_usage[] = {\n \tN_(\"git add [<options>] [--] <pathspec>...\"),\n@@ -36,6 +38,7 @@ static int take_worktree_changes;\n static int add_renormalize;\n static int pathspec_file_nul;\n static int include_sparse;\n+static int no_verify;\n static const char *pathspec_from_file;\n \n static int chmod_pathspec(struct repository *repo,\n@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {\n \tOPT_BOOL( 0 , \"refresh\", &refresh_only, N_(\"don't add, only refresh the index\")),\n \tOPT_BOOL( 0 , \"ignore-errors\", &ignore_add_errors, N_(\"just skip files which cannot be added because of errors\")),\n \tOPT_BOOL( 0 , \"ignore-missing\", &ignore_missing, N_(\"check if - even missing - files are ignored in dry run\")),\n+\tOPT_BOOL( 0 , \"no-verify\", &no_verify, N_(\"bypass pre-add hook\")),\n \tOPT_BOOL(0, \"sparse\", &include_sparse, N_(\"allow updating entries outside of the sparse-checkout cone\")),\n \tOPT_STRING(0, \"chmod\", &chmod_arg, \"(+|-)x\",\n \t\t   N_(\"override the executable bit of the listed files\")),\n@@ -391,6 +395,8 @@ int cmd_add(int argc,\n \tchar *ps_matched = NULL;\n \tstruct lock_file lock_file = LOCK_INIT;\n \tstruct odb_transaction *transaction;\n+\tint run_pre_add = 0;\n+\tchar *orig_index_path = NULL;\n \n \trepo_config(repo, add_config, NULL);\n \n@@ -576,6 +582,11 @@ int cmd_add(int argc,\n \t\tstring_list_clear(&only_match_skip_worktree, 0);\n \t}\n \n+\tif (!show_only && !no_verify && hook_exists(repo, \"pre-add\")) {\n+\t\trun_pre_add = 1;\n+\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n+\t}\n+\n \ttransaction = odb_transaction_begin(repo->objects);\n \n \tps_matched = xcalloc(pathspec.nr, 1);\n@@ -598,9 +609,30 @@ int cmd_add(int argc,\n \todb_transaction_commit(transaction);\n \n finish:\n-\tif (write_locked_index(repo->index, &lock_file,\n-\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n-\t\tdie(_(\"unable to write new index file\"));\n+\tif (run_pre_add && repo->index->cache_changed) {\n+\t\tstruct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;\n+\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\tSKIP_INDEX_CHANGE_HOOK))\n+\t\t\tdie(_(\"unable to write proposed index\"));\n+\n+\t\tstrvec_push(&opt.args, orig_index_path);\n+\t\tstrvec_push(&opt.args, get_lock_file_path(&lock_file));\n+\t\tif (run_hooks_opt(repo, \"pre-add\", &opt)) {\n+\t\t\trollback_lock_file(&lock_file); /* hook rejected */\n+\t\t\texit_status = 1;\n+\t\t} else if (commit_lock_file(&lock_file)) {\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t\t} else {\n+\t\t\temit_post_index_change(repo->index);\n+\t\t}\n+\t} else {\n+\t\tif (write_locked_index(repo->index, &lock_file,\n+\t\t\t\t       COMMIT_LOCK | SKIP_IF_UNCHANGED))\n+\t\t\tdie(_(\"unable to write new index file\"));\n+\t}\n+\n+\tfree(orig_index_path);\n \n \tfree(ps_matched);\n \tdir_clear(&dir);\ndiff --git a/read-cache-ll.h b/read-cache-ll.h\nindex 71b49d9af4..eed1d74d99 100644\n--- a/read-cache-ll.h\n+++ b/read-cache-ll.h\n@@ -284,6 +284,9 @@ int is_index_unborn(struct index_state *);\n /* For use with `write_locked_index()`. */\n #define COMMIT_LOCK\t\t(1 << 0)\n #define SKIP_IF_UNCHANGED\t(1 << 1)\n+#define SKIP_INDEX_CHANGE_HOOK\t(1 << 2)\n+\n+void emit_post_index_change(struct index_state *istate);\n \n /*\n  * Write the index while holding an already-taken lock. Close the lock,\ndiff --git a/read-cache.c b/read-cache.c\nindex 0c07c3aef7..dfe8d8e4d7 100644\n--- a/read-cache.c\n+++ b/read-cache.c\n@@ -3161,13 +3161,18 @@ static int do_write_locked_index(struct index_state *istate,\n \telse\n \t\tret = close_lock_file_gently(lock);\n \n+\tif (!(flags & SKIP_INDEX_CHANGE_HOOK))\n+\t\temit_post_index_change(istate);\n+\treturn ret;\n+}\n+\n+void emit_post_index_change(struct index_state *istate)\n+{\n \trun_hooks_l(the_repository, \"post-index-change\",\n \t\t    istate->updated_workdir ? \"1\" : \"0\",\n \t\t    istate->updated_skipworktree ? \"1\" : \"0\", NULL);\n \tistate->updated_workdir = 0;\n \tistate->updated_skipworktree = 0;\n-\n-\treturn ret;\n }\n \n static int write_split_index(struct index_state *istate,\ndiff --git a/t/meson.build b/t/meson.build\nindex f80e366cff..2419a9adbb 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -415,6 +415,7 @@ integration_tests = [\n   't3703-add-magic-pathspec.sh',\n   't3704-add-pathspec-file.sh',\n   't3705-add-sparse-checkout.sh',\n+  't3706-pre-add-hook.sh',\n   't3800-mktag.sh',\n   't3900-i18n-commit.sh',\n   't3901-i18n-patch.sh',\ndiff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh\nnew file mode 100755\nindex 0000000000..352b79e5d6\n--- /dev/null\n+++ b/t/t3706-pre-add-hook.sh\n@@ -0,0 +1,304 @@\n+#!/bin/sh\n+\n+test_description='pre-add hook tests\n+\n+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).'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'with no hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success POSIXPERM 'with non-executable hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\tchmod -x .git/hooks/pre-add &&\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success '--no-verify with no hook' '\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'with succeeding hook' '\n+\ttest_when_finished \"rm -f actual expected\" &&\n+\techo \"pre-add\" >expected &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add >>actual\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add file &&\n+\ttest_cmp expected actual\n+'\n+\n+test_expect_success 'with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre-add-rejected >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\ttest_must_fail git add file\n+'\n+\n+test_expect_success '--no-verify with failing hook' '\n+\ttest_when_finished \"rm -f actual\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo should-not-run >>actual\n+\texit 1\n+\tEOF\n+\n+\techo content >file &&\n+\tgit add --no-verify file &&\n+\ttest_path_is_missing actual\n+'\n+\n+test_expect_success 'setup for path-based tests' '\n+\tgit add file &&\n+\tgit commit -m \"initial\"\n+'\n+\n+test_expect_success 'hook found via core.hooksPath' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -rf custom-hooks &&\n+\t\t\t    git config --unset core.hooksPath\" &&\n+\tmkdir custom-hooks &&\n+\twrite_script custom-hooks/pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\tgit config core.hooksPath custom-hooks &&\n+\techo changed >>file &&\n+\tgit add file &&\n+\ttest_path_is_file hook-ran &&\n+\trm -f hook-ran\n+'\n+\n+test_expect_success 'hook receives index-path and lockfile-path arguments' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f staged expect-count arg-count arg-one arg-two \\\n+\t\t\t    expect-index expect-lockpath\" &&\n+\techo staged >staged &&\n+\tcat >expect-count <<-\\EOF &&\n+\t2\n+\tEOF\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$#\" >arg-count &&\n+\techo \"$1\" >arg-one &&\n+\techo \"$2\" >arg-two &&\n+\ttest \"$1\" != \"$2\" &&\n+\ttest -r \"$2\"\n+\tEOF\n+\tgit add staged &&\n+\ttest_cmp expect-count arg-count &&\n+\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\tsed \"s/$/.lock/\" expect-index >expect-lockpath &&\n+\ttest_cmp expect-lockpath arg-two\n+'\n+\n+test_expect_success 'hook rejection leaves final index unchanged' '\n+\ttest_when_finished \"git reset --hard && rm -f reject index.before\" &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\techo reject >reject &&\n+\ttest_must_fail git add reject &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'missing pre-existing index path treated as empty' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f newfile arg-one after.raw after expect-index\" &&\n+\trm -f .git/index &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$1\" >arg-one &&\n+\ttest ! -e \"$1\" &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >after.raw &&\n+\tsort after.raw >after\n+\tEOF\n+\techo newfile >newfile &&\n+\tgit add newfile &&\n+\tprintf \"%s/index\\n\" \"$(git rev-parse --absolute-git-dir)\" >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\tgrep \"^newfile$\" after &&\n+\tgrep \"^file$\" after\n+'\n+\n+test_expect_success 'hook respects GIT_INDEX_FILE' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f arg-one arg-two expect-index expect-lockpath \\\n+\t\t\t    alt-index alt-index.lock\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo \"$1\" >arg-one &&\n+\techo \"$2\" >arg-two\n+\tEOF\n+\techo changed >>file &&\n+\tGIT_INDEX_FILE=alt-index git add file &&\n+\ttest-tool path-utils absolute_path alt-index >expect-index &&\n+\ttest_cmp expect-index arg-one &&\n+\ttest-tool path-utils absolute_path alt-index.lock >expect-lockpath &&\n+\ttest_cmp expect-lockpath arg-two\n+'\n+\n+test_expect_success 'setup for mixed-result tests' '\n+\techo \"*.ignored\" >.gitignore &&\n+\tgit add .gitignore &&\n+\tgit commit -m \"add gitignore\"\n+'\n+\n+test_expect_success 'mixed-result add invokes pre-add hook' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f bad.ignored index.before hook-ran proposed\" &&\n+\techo changed >>file &&\n+\techo ignored >bad.ignored &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n+\tgrep \"^file$\" proposed &&\n+\techo invoked >hook-ran &&\n+\texit 1\n+\tEOF\n+\ttest_must_fail git add file bad.ignored &&\n+\ttest_path_is_file hook-ran &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'mixed-result add stages tracked update on approve' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f bad.ignored hook-ran staged proposed\" &&\n+\techo changed >>file &&\n+\techo ignored >bad.ignored &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >proposed &&\n+\tgrep \"^file$\" proposed &&\n+\techo invoked >hook-ran\n+\tEOF\n+\ttest_must_fail git add file bad.ignored &&\n+\ttest_path_is_file hook-ran &&\n+\tgit diff --cached --name-only HEAD >staged &&\n+\tgrep \"^file$\" staged &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success 'post-index-change fires after pre-add approval' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f hook-order expect lockfile-present\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre >>hook-order\n+\tEOF\n+\ttest_hook post-index-change <<-\\EOF &&\n+\tif test -f \".git/index.lock\"\n+\tthen\n+\t\techo locked >lockfile-present\n+\tfi\n+\techo post >>hook-order\n+\tEOF\n+\techo updated >>file &&\n+\tgit add file &&\n+\tcat >expect <<-\\EOF &&\n+\tpre\n+\tpost\n+\tEOF\n+\ttest_cmp expect hook-order &&\n+\ttest_path_is_missing lockfile-present\n+'\n+\n+test_expect_success 'post-index-change is suppressed on pre-add rejection' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f index.before hook-order expect\" &&\n+\tcp .git/index index.before &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo pre >>hook-order &&\n+\texit 1\n+\tEOF\n+\ttest_hook post-index-change <<-\\EOF &&\n+\techo post >>hook-order\n+\tEOF\n+\techo reject >>file &&\n+\ttest_must_fail git add file &&\n+\techo pre >expect &&\n+\ttest_cmp expect hook-order &&\n+\ttest_cmp_bin index.before .git/index &&\n+\ttest_path_is_missing .git/index.lock\n+'\n+\n+test_expect_success '--dry-run does not invoke hook' '\n+\ttest_when_finished \"rm -f hook-ran dry\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\techo dry >dry &&\n+\tgit add --dry-run dry &&\n+\ttest_path_is_missing hook-ran\n+'\n+\n+test_expect_success 'hook runs for git add -u' '\n+\ttest_when_finished \"git reset --hard && rm -f hook-ran\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\techo invoked >hook-ran\n+\tEOF\n+\techo changed >>file &&\n+\tgit add -u &&\n+\ttest_path_is_file hook-ran\n+'\n+\n+test_expect_success 'hook example: block .env files' '\n+\ttest_when_finished \"git reset --hard &&\n+\t\t\t    rm -f .env safe.txt new-paths\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached --name-only HEAD >new-paths &&\n+\twhile read path\n+\tdo\n+\t\tcase \"$path\" in\n+\t\t*.env)\n+\t\t\techo \"error: $path must not be staged\" >&2\n+\t\t\texit 1\n+\t\t\t;;\n+\t\tesac\n+\tdone <new-paths\n+\tEOF\n+\techo \"DB_PASS=secret\" >.env &&\n+\ttest_must_fail git add .env &&\n+\techo \"safe content\" >safe.txt &&\n+\tgit add safe.txt\n+'\n+\n+test_expect_success 'hook example: block secrets in content' '\n+\ttest_when_finished \"git reset --hard && rm -f config.txt secret\" &&\n+\ttest_hook pre-add <<-\\EOF &&\n+\tGIT_INDEX_FILE=\"$2\" git diff --cached HEAD >secret &&\n+\tif grep -q \"API_KEY=\" secret ||\n+\t   grep -q \"SECRET_KEY=\" secret ||\n+\t   grep -q \"PRIVATE_KEY=\" secret\n+\tthen\n+\t\techo \"error: staged content contains secrets\" >&2\n+\t\texit 1\n+\tfi\n+\tEOF\n+\techo \"API_KEY=sksksk-live-12345\" >config.txt &&\n+\ttest_must_fail git add config.txt &&\n+\techo \"LOG_LEVEL=debug\" >config.txt &&\n+\tgit add config.txt\n+'\n+\n+test_done\n\nbase-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4\n-- \ngitgitgadget\n"},{"id":"537929","messageId":"TqpXjikveTe2dR39_ZEgb0bz0KLLFtaHN_Exd-wwOUAR2RkfcuWBnPUY7wvsTDLaISn_a-cfzssvflKTr_5lSIisfLG6OvGmdEg9gNTIbng=@pm.me","threadId":"64972","inReplyTo":"87seaexz33.fsf@gentoo.mail-host-address-is-not-set","subject":"Re: [PATCH v4] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-03-05T12:37:45Z","receivedAt":"2026-03-05T12:37:51Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"Hi all, \n\nThanks for the review, Adrian. v5 addresses both points you brought up. Also, I fixed a failing test on windows CI with proper path formatting.\n\nChandra Kethi-Reddy\n@archonphronesis:matrix.org\n\nSent with Proton Mail secure email.\n\nOn Thursday, March 5th, 2026 at 5:44 PM, Adrian Ratiu <adrian.ratiu@collabora.com> wrote:\n\n> Hi Chandra,\n> \n> On Thu, 05 Mar 2026, \"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com> wrote:\n> > @@ -576,6 +582,11 @@ int cmd_add(int argc,\n> >  \t\tstring_list_clear(&only_match_skip_worktree, 0);\n> >  \t}\n> >\n> > +\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n> > +\t\trun_pre_add = 1;\n> > +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n> > +\t}\n> > +\n> \n> Please use hook_exists() instead of find_hook() because that works with\n> hooks defined via config files. Otherwise your hooks API usage is great.\n> \n> Maybe add a test or two which define the pre-add hook via configs to\n> verify it works?\n> \n> (regarding find_hook(), we sholud mark it as deprecated or convert all\n> its remaining uses and remove it, however that's outside the scope of\n> your series, no worries)\n> \n> \n"},{"id":"537952","messageId":"87o6l2xuku.fsf@collabora.com","threadId":"64972","inReplyTo":"pull.2045.v5.git.1772714253412.gitgitgadget@gmail.com","subject":"Re: [PATCH v5] add: support pre-add hook","fromName":"Adrian Ratiu","fromEmail":"adrian.ratiu@collabora.com","sentAt":"2026-03-05T13:41:05Z","receivedAt":"2026-03-05T13:41:20Z","isPatch":true,"sender":{"key":"adrian.ratiu@collabora.com","avatar":"https://avatars.githubusercontent.com/u/12472556?v=4"},"body":"Hi again Chandra,\n\nOn Thu, 05 Mar 2026, \"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com> wrote:\n> Range-diff vs v4:\n>\n>  1:  9383395bb0 ! 1:  fc58c4cba2 add: support pre-add hook\n>      @@ builtin/add.c: int cmd_add(int argc,\n>        \t\tstring_list_clear(&only_match_skip_worktree, 0);\n>        \t}\n>        \n>      -+\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n>      ++\tif (!show_only && !no_verify && hook_exists(repo, \"pre-add\")) {\n>       +\t\trun_pre_add = 1;\n>       +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n>       +\t}\n>      @@ t/t3706-pre-add-hook.sh (new)\n>       +\tgit commit -m \"initial\"\n>       +'\n>       +\n>      ++test_expect_success 'hook found via core.hooksPath' '\n>      ++\ttest_when_finished \"git reset --hard &&\n>      ++\t\t\t    rm -rf custom-hooks &&\n>      ++\t\t\t    git config --unset core.hooksPath\" &&\n>      ++\tmkdir custom-hooks &&\n>      ++\twrite_script custom-hooks/pre-add <<-\\EOF &&\n>      ++\techo invoked >hook-ran\n>      ++\tEOF\n>      ++\tgit config core.hooksPath custom-hooks &&\n>      ++\techo changed >>file &&\n>      ++\tgit add file &&\n>      ++\ttest_path_is_file hook-ran &&\n>      ++\trm -f hook-ran\n>      ++'\n\nThe test you added is rather surprising, was it written by Claude AI?\n\nFor clarification, what I asked for is to add tests which define the new\nhook via configs like done in t1800-hook.sh tests, for example in your\ncase, you can define a simple test like this:\n\ntest_config hook.my-friendly-echo.event \"pre-add\" &&\ntest_config hook.my-friendly-echo.command \"echo hello from hook\" &&\n\nSee Documentation/config/hook.adoc for more details.\n\nThe turnaround in minutes between v4 -> v5 is also surprising.\nPlease give humans a chance to review & respond, at least a couple of\ndays between resvisions. :)\n\nThanks,\nAdrian\n"},{"id":"537953","messageId":"i6rBoZXrLVKU8Yc6UtDNpPB0KJ8dbQlyGzxKF_ofhIiSfScuKHcJHewPAmWX3kiF5vr9uNuSQkJg7NbCV9VEKfMs-ez09VKhfMX8u84n5qU=@pm.me","threadId":"64972","inReplyTo":"87o6l2xuku.fsf@collabora.com","subject":"Re: [PATCH v5] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-03-05T13:46:16Z","receivedAt":"2026-03-05T13:46:28Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"Hi Adrian,\n\nYes, I used Claude CLI to suggest changes in a markdown file and then added some changes from there myself. Claude suggested a test to do what you asked here, but I thought it redundant since after the hook is discovered everything should work the same. Claude agreed, but AI tends to agree when you push back on most things. Please let me know if my intuition here about test redundancy was wrong and I should go ahead with adding that exercise.\n\n\nChandra Kethi-Reddy\n@archonphronesis:matrix.org\n\nSent from Proton Mail for iOS.\n\n-------- Original Message --------\nOn Thursday, 03/05/26 at 19:11 Adrian Ratiu <adrian.ratiu@collabora.com> wrote:\nHi again Chandra,\n\nOn Thu, 05 Mar 2026, \"Chandra Kethi-Reddy via GitGitGadget\" <gitgitgadget@gmail.com> wrote:\n> Range-diff vs v4:\n>\n>  1:  9383395bb0 ! 1:  fc58c4cba2 add: support pre-add hook\n>      @@ builtin/add.c: int cmd_add(int argc,\n>        \t\tstring_list_clear(&only_match_skip_worktree, 0);\n>        \t}\n>\n>      -+\tif (!show_only && !no_verify && find_hook(repo, \"pre-add\")) {\n>      ++\tif (!show_only && !no_verify && hook_exists(repo, \"pre-add\")) {\n>       +\t\trun_pre_add = 1;\n>       +\t\torig_index_path = absolute_pathdup(repo_get_index_file(repo));\n>       +\t}\n>      @@ t/t3706-pre-add-hook.sh (new)\n>       +\tgit commit -m \"initial\"\n>       +'\n>       +\n>      ++test_expect_success 'hook found via core.hooksPath' '\n>      ++\ttest_when_finished \"git reset --hard &&\n>      ++\t\t\t    rm -rf custom-hooks &&\n>      ++\t\t\t    git config --unset core.hooksPath\" &&\n>      ++\tmkdir custom-hooks &&\n>      ++\twrite_script custom-hooks/pre-add <<-\\EOF &&\n>      ++\techo invoked >hook-ran\n>      ++\tEOF\n>      ++\tgit config core.hooksPath custom-hooks &&\n>      ++\techo changed >>file &&\n>      ++\tgit add file &&\n>      ++\ttest_path_is_file hook-ran &&\n>      ++\trm -f hook-ran\n>      ++'\n\nThe test you added is rather surprising, was it written by Claude AI?\n\nFor clarification, what I asked for is to add tests which define the new\nhook via configs like done in t1800-hook.sh tests, for example in your\ncase, you can define a simple test like this:\n\ntest_config hook.my-friendly-echo.event \"pre-add\" &&\ntest_config hook.my-friendly-echo.command \"echo hello from hook\" &&\n\nSee Documentation/config/hook.adoc for more details.\n\nThe turnaround in minutes between v4 -> v5 is also surprising.\nPlease give humans a chance to review & respond, at least a couple of\ndays between resvisions. :)\n\nThanks,\nAdrian\n\n\n"},{"id":"537975","messageId":"98531f78-cf04-4e64-ac7c-6a13e52aee54@gmail.com","threadId":"64972","inReplyTo":"pull.2045.v5.git.1772714253412.gitgitgadget@gmail.com","subject":"Re: [PATCH v5] add: support pre-add hook","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-03-05T14:37:46Z","receivedAt":"2026-03-05T14:38:07Z","isPatch":true,"sender":{"key":"phillip.wood@dunelm.org.uk","avatar":null},"body":"On 05/03/2026 12:37, Chandra Kethi-Reddy via GitGitGadget wrote:\n> \n> diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc\n> index 6192daeb03..a3ff4ced83 100644\n> --- a/Documentation/git-add.adoc\n> +++ b/Documentation/git-add.adoc\n> [...]   \n> @@ -42,10 +42,11 @@ use the `--force` option to add ignored files. If you specify the exact\n>   filename of an ignored file, `git add` will fail with a list of ignored\n>   files. Otherwise it will silently ignore the file.\n>   \n> +A `pre-add` hook can be used to reject `git add` (see linkgit:githooks[5]).\n\ngit-commit.adoc has a separate section for HOOKS, perhaps we should do \nthe same here. It would be clearer to say the that the proposed changes \nare rejected rather than `git add` itself.\n\n> diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc\n> index 056553788d..90945a590e 100644\n> --- a/Documentation/githooks.adoc\n> +++ b/Documentation/githooks.adoc\n> @@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.\n>   This hook is meant primarily for notification, and cannot affect\n>   the outcome of `git am`.\n>   \n> +pre-add\n> +~~~~~~~\n> +\n> +This hook is invoked by linkgit:git-add[1], and can be bypassed with the\n> +`--no-verify` option. It is not invoked for `--interactive`, `--patch`,\n> +`--edit`, or `--dry-run`.\n\nI'm struggling to see how it is helpful to the user for \"git add \n--dry-run $path\" to succeed when \"git add $path\" will be rejected by the \n\"pre-add\" hook.\n\nThe other options all use \"git apply\" to apply a diff to the index so \nthey could apply the patch to a temporary index which is then passed to \nthe \"pre-add\" hook. If the hook fails the user should be given the \noption to re-edit the patch or re-select the hunks so that their work is \nnot wasted.\n\nTo me this hook would be much more useful if it also checked changes \nstaged by \"git commit\" - it is still staging changes after all.\n\n> +It takes two arguments: the path to the index file for this invocation\n> +of `git add`, and the path to the lockfile containing the proposed\n\nCalling it a lockfile is rather confusing - it is just second index file \nthat contains the changes that would be staged.\n\n> +index after staging. If no index exists yet, the first argument names\n> +a path that does not exist and should be treated as an empty index.\n> +\n> +The hook is invoked after the index has been updated in memory and\n> +written to the lockfile, but before it is committed to the final index\n> +path. Exiting with a non-zero status causes `git add` to reject the\n> +proposed state, roll back the lockfile, and leave the index unchanged.\n> +Exiting with zero status allows the index update to be committed. The\n> +hook accepts or rejects the entire proposed update; per-path filtering\n> +is not supported. Both files should be treated as read-only by the hook.\n\nIf we don't enforce them being read-only people will write hooks that \nupdate them just as they do for \"pre-commit\" hooks. Once they start \nrelying on that they will complain if we stop supporting it. If we lock \nboth index files before running the hook I think that will prevent the \nhook from being able to update them.\n\n> +Hook authors may set `GIT_INDEX_FILE=\"$1\"` to inspect the current index\n> +state and `GIT_INDEX_FILE=\"$2\"` to inspect the proposed index state.\n\nWe should be explicit that the proposed index state contains all the \nchanges that would be committed so staging changes incrementally will \ncheck them multiple times.\n\n> +This hook can be used to prevent staging of files based on names, content,\n> +or sizes (e.g., to block `.env` files, secret keys, or large files).\n> +\n> +This hook is not invoked by `git commit -a` or `git commit --include`\n\nI would be more accurate to say that it is not invoked by `git commit` \nat all as there are several ways of staging changes including `git \ncommit $path`. We should also be explicit that in order to ensure that \nall staged changes are checked the checks in the \"pre-add\" hook must be \nduplicated by the \"pre-commit\" hook.\n\n> +which still can run the `pre-commit` hook, providing a control point at\n> +commit time.\n\nWhile I've commented on the documentation, I think it is really the \ndesign that needs working on. I like the idea of giving feedback earlier \nwhen staging changes rather than waiting for the user to run \"git \ncommit\" but I think we need a more coherent approach to when the hook is \nrun.\n\nThanks\n\nPhillip\n\n"},{"id":"537977","messageId":"87h5qujps7.fsf@gitster.g","threadId":"64972","inReplyTo":"27ee9a9c-0caa-4b6e-a968-51c71c8b6e5f@gmail.com","subject":"Re: [PATCH v3] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-05T14:48:24Z","receivedAt":"2026-03-05T14:48:29Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> paths that are staged by the current invocation of \"git add\". That means \n> if for some reason I need to bypass the hook when running \"git add\" I'll \n> have to bypass it every time until I commit and cannot check the other \n> changes that I'm staging. It also means that running \"git add\" several \n> times, each with a different path runs the hook multiple times on the \n> same content.\n\nCorrect.  You'd need \"git diff --name-only HEAD\" twice and run the\nresults through \"comm -13\" or something.\n\n> These caveats are rather unfortunate as it means to be sure that staged \n> changes get checked I have to duplicate the \"pre-add\" checks in the \n> \"pre-commit\" hook which is rather inefficient. It would be very nice to \n> be able to check changes as they're staged rather than just before they \n> are committed but I can't help feeling that what's proposed here is \n> driven by ease of implementation which leads to a rather incoherent user \n> experience.\n\nTrue.\n\nAs I already said, I am not sure of the value of the proposed hook.\n\nThanks.\n"},{"id":"537991","messageId":"xmqqwlzq2i96.fsf@gitster.g","threadId":"64972","inReplyTo":"87o6l2xuku.fsf@collabora.com","subject":"Re: [PATCH v5] add: support pre-add hook","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-05T19:23:01Z","receivedAt":"2026-03-05T19:23:03Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n\n> The turnaround in minutes between v4 -> v5 is also surprising.\n> Please give humans a chance to review & respond, at least a couple of\n> days between resvisions. :)\n\nAs we saw Patrick did in another thread, I often take such a short\nturnaround as a bad sign that the humans are not paying enough\nattention to what they are sending out on the authoring side, i.e.,\nthe new iterations are probably outpacing not just reviewers but the\nauthors ;-)\n"},{"id":"538048","messageId":"Mas-XsZDLQf822y8cXTnllJLDJcd9vU8jRd7i4tj-7pCw90hurfkTos1piH-zF-g9A-IPM2sIZoXac1MB2yHn9oU-nX9kaLeuI9bXWp3Fbw=@pm.me","threadId":"64972","inReplyTo":"xmqqwlzq2i96.fsf@gitster.g","subject":"Re: [PATCH v5] add: support pre-add hook","fromName":"Chandra","fromEmail":"chandrakr@pm.me","sentAt":"2026-03-06T02:20:25Z","receivedAt":"2026-03-06T02:20:33Z","isPatch":true,"sender":{"key":"chandrakr@pm.me","avatar":null},"body":"Thanks all for the thorough review. I took some time to sit with the feedback and review how pre-commit and pre-push handles these cases.\n\nPhillip Wood <phillip.wood123@gmail.com> writes:\n\n> git-commit.adoc has a seperate section for HOOKS\n> It would be clearer to say that the proposed changes are rejected\n\nAgreed. I can add that.\n\n> I'm struggling to see how it is helpful to the user for \"git add\n> --dry-run $path\" to succeed when \"git add $path\" will be rejected\n\nThe --dry-run on commit also skips the pre-commit hook (builtin/commit.c returns early at the dry_run check before run_commit_hook is reached). Pre-add follows the same convention. As I understand it, --dry-run answers what would be staged without side effects, including hooks.\n\nI can see the argument for running the hook during --dry-run so users can preview rejections. After all, git push --dry-run runs the pre-push hook. If the consensus is that pre-add should diverge from pre-commit here and follow pre-push, I'm happy to add that, but I think it would be better for consistent --dry-run hooking to be a separate patch series applied to both add and commit.\n\n> The other options all use \"git apply\" to apply a diff to the index\n> so they could apply the patch to a temporary index which is then\n> passed to the \"pre-add\" hook. If the hook fails the user should be\n> given the option to re-edit the patch or re-select the hunks so\n> that their work is not wasted.\n\npre-commit has the same gap as `git commit --interactive` and `git commit --patch` run interactive staging and then the pre-commit hook runs on the result. If the hook rejects, the user's interactive selections are lost with no re-edit prompt.\n\nI think it's a good idea to add retry/re-edit UX for --interactive and --patch, but it would be new behavior. IMO, it makes sense to keep v1 of pre-add consistent with how pre-commit works today, and do a follow-up series for re-edit support in both hooks.\n\n> To me this hook would be much more useful if it also checked\n> changes staged by \"git commit\"\n\nThis is essentially asking pre-add to become a universal pre-staging hook, which I was fully in favor of earlier in this conversation. However, that is a much larger scope than intended for this patch series, as each of the git commit staging integrations have their own codepaths in prepare_index(). The pre-commit hook already covers the commit-time check, and the default pre-applypatch hook runs pre-commit for the same reason. I'm open to these changes, but I don't think it makes sense within the scope of this patch series.\n\n> Calling it a lockfile is rather confusing\n\nWhile it is literally the file created by the lock_file API, I can see the point that hook authors may not care about the locking mechanism more than they care that it's the proposed index. \n\n> If we don't enforce them being read-only people will write hooks\n> that update them just as they do for \"pre-commit\" hooks.\n\nTrue, while the documentation says it should be treated as read-only, there's no enforcement here. On the other hand, if users are doing this for pre-commit, maybe it's better they're not read-only because there are use cases for that affordance? I'm not sure about whether to actually force it to be read-only or to allow users to do what they do with pre-commit hooks.\n\n> We should be explicit that the proposed index state contains all\n> the changes that would be committed so staging changes\n> incrementally will check them multiple times.\n\nYes. \n\n> I would be more accurate to say that it is not invoked by \n> `git commit` at all\n\nAlso yes.\n\nAdrian Ratiu <adrian.ratiu@collabora.com> writes:\n\n> Maybe add a test or two which define the pre-add hook via configs\n\nI see now that what I thought was a redundant codepath test earlier was actually not.\n\nThe hook.<name>.event / hook.<name>.command config infrastructure is in `next` but hasn't graduated to `master` yet. I'll write that test once ar/config-hooks lands in `master` but I'm sure functionally it will work because of the switch you suggested from find_hook() to hook_exists(). \n\n> The turnaround in minutes between v4 -> v5 is also surprising.\n\nUnderstood. I can wait for more review feedback before sending new updates. \n\nI will note that I personally handtype every line of test, code, and docs that I commit although I use Claude and Codex for assistance and recommendations. They have been invaluable aids since this is my first contribution and I don't have extensive experience with git internals. I'm sure I make mistakes due to being a neophyte here (and frankly I wouldn't claim C or shell in the top 5 languages I'm skilled/experienced with). I believe AI Disclosure is an ethical requirement, particularly in an open-source code base like this, in spite of reputational risks. If it induces reviewers to be more stringent, that is good, because it reduces the likelihood of mistakes passing through.\n\nI am grateful for everyone's feedback. I believe this change is needed and will help a lot of users (including myself) who currently use weird workarounds like aliases to shell scripts. Pushback is essential for quality and surfacing opportunities for improvement. Thank you for the time spent reviewing these changes.\n\nChandra Kethi-Reddy\n@archonphronesis:matrix.org\n\nSent with Proton Mail secure email.\n\n"},{"id":"538900","messageId":"3b2b67b1-3748-4a95-9882-30e4ba349922@gmail.com","threadId":"64972","inReplyTo":"Mas-XsZDLQf822y8cXTnllJLDJcd9vU8jRd7i4tj-7pCw90hurfkTos1piH-zF-g9A-IPM2sIZoXac1MB2yHn9oU-nX9kaLeuI9bXWp3Fbw=@pm.me","subject":"Re: [PATCH v5] add: support pre-add hook","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-03-13T14:39:45Z","receivedAt":"2026-03-13T14:39:49Z","isPatch":true,"sender":{"key":"phillip.wood@dunelm.org.uk","avatar":null},"body":"On 06/03/2026 02:20, Chandra wrote:\n> \n>> I'm struggling to see how it is helpful to the user for \"git add\n>> --dry-run $path\" to succeed when \"git add $path\" will be rejected\n> \n> The --dry-run on commit also skips the pre-commit hook\n > (builtin/commit.c returns early at the dry_run check before\n > run_commit_hook is reached). Pre-add follows the same convention. As I\n > understand it, --dry-run answers what would be staged without side\n > effects, including hooks.\n\nI was surprised that \"git commit --dry-run\" ignores the pre-commit hook. \nLooking at the history before \"--dry-run\" was introduced users had to \nrun \"git status\" to see what would be committed. When \"--dry-run\" was \nintroduced it was intended to replicate the output \"git status\", then \n\"git status\" was expanded to provide more information rather than just \nwhat would be committed. That explains why \"--dry-run\" does not run the \npre-commit hook but the end result is that it is less useful than it \ncould be and I don't think we should repeat that for the pre-add hook.\n\n\n> I can see the argument for running the hook during --dry-run so users\n > can preview rejections. After all, git push --dry-run runs the pre\n > push hook. If the consensus is that pre-add should diverge from pre\n > commit here and follow pre-push, I'm happy to add that, but I think it\n > would be better for consistent --dry-run hooking to be a separate\n > patch series applied to both add and commit.\n\nMaybe but as the \"git commit --dry-run\" behavior is sub-optimal it might \nbe better to avoid repeating that. As you say it is already in \nconsistent with \"git push --dry-run\".\n\n>> The other options all use \"git apply\" to apply a diff to the index\n>> so they could apply the patch to a temporary index which is then\n>> passed to the \"pre-add\" hook. If the hook fails the user should be\n>> given the option to re-edit the patch or re-select the hunks so\n>> that their work is not wasted.\n> \n> pre-commit has the same gap as `git commit --interactive` and\n > `git commit --patch` run interactive staging and then the pre-commit\n > hook runs on the result. If the hook rejects, the user's interactive\n > selections are lost with no re-edit prompt.>\n> I think it's a good idea to add retry/re-edit UX for --interactive\n > and --patch, but it would be new behavior. IMO, it makes sense to keep\n > v1 of pre-add consistent with how pre-commit works today, and do a\n > follow-up series for re-edit support in both hooks.\n\nThe pre-commit hook is run for --iteractive and --patch though which the \npre-add hook isn't so they are not consistent. I agree that the re-edit \nsupport could come later.\n\n>> To me this hook would be much more useful if it also checked\n>> changes staged by \"git commit\"\n> \n> This is essentially asking pre-add to become a universal pre-staging\n > hook, which I was fully in favor of earlier in this conversation.\n > However, that is a much larger scope than intended for this patch\n > series, as each of the git commit staging integrations have their own\n > codepaths in prepare_index(). The pre-commit hook already covers the\n > commit-time check, and the default pre-applypatch hook runs pre-commit\n > for the same reason. I'm open to these changes, but I don't think it\n > makes sense within the scope of this patch series.\n\nI can see it is more work, but I'm not convinced that a pre-add hook \nimplemented with all the caveats that are in this series without a \nconcrete plan to fill those gaps adds much value. It leaves us with a \nvery confusing UI.\n\n>> If we don't enforce them being read-only people will write hooks\n>> that update them just as they do for \"pre-commit\" hooks.\n> \n> True, while the documentation says it should be treated as\n > read-only, there's no enforcement here. On the other hand, if users\n > are doing this for pre-commit, maybe it's better they're not read-only\n > because there are use cases for that affordance? I'm not sure about\n > whether to actually force it to be read-only or to allow users to do\n > what they do with pre-commit hooks.\nI think our comment to supporting pre-commit hooks that update the index \nhas been a bit patchy. It would be better to either commit to allowing \nthe \"pre-add\" hook to update the index (after thinking about the future \nimplications of that) or enforce it to be read-only.\n\nThanks\n\nPhillip\n\n>> We should be explicit that the proposed index state contains all\n>> the changes that would be committed so staging changes\n>> incrementally will check them multiple times.\n> \n> Yes.\n> \n>> I would be more accurate to say that it is not invoked by\n>> `git commit` at all\n> \n> Also yes.\n> \n> Adrian Ratiu <adrian.ratiu@collabora.com> writes:\n> \n>> Maybe add a test or two which define the pre-add hook via configs\n> \n> I see now that what I thought was a redundant codepath test earlier was actually not.\n> \n> The hook.<name>.event / hook.<name>.command config infrastructure is in `next` but hasn't graduated to `master` yet. I'll write that test once ar/config-hooks lands in `master` but I'm sure functionally it will work because of the switch you suggested from find_hook() to hook_exists().\n> \n>> The turnaround in minutes between v4 -> v5 is also surprising.\n> \n> Understood. I can wait for more review feedback before sending new updates.\n> \n> I will note that I personally handtype every line of test, code, and docs that I commit although I use Claude and Codex for assistance and recommendations. They have been invaluable aids since this is my first contribution and I don't have extensive experience with git internals. I'm sure I make mistakes due to being a neophyte here (and frankly I wouldn't claim C or shell in the top 5 languages I'm skilled/experienced with). I believe AI Disclosure is an ethical requirement, particularly in an open-source code base like this, in spite of reputational risks. If it induces reviewers to be more stringent, that is good, because it reduces the likelihood of mistakes passing through.\n> \n> I am grateful for everyone's feedback. I believe this change is needed and will help a lot of users (including myself) who currently use weird workarounds like aliases to shell scripts. Pushback is essential for quality and surfacing opportunities for improvement. Thank you for the time spent reviewing these changes.\n> \n> Chandra Kethi-Reddy\n> @archonphronesis:matrix.org\n> \n> Sent with Proton Mail secure email.\n> \n\n"}]}