From: "Chandra Kethi-Reddy via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Chandra Kethi-Reddy <chandrakr@pm.me>,
Chandra Kethi-Reddy <chandrakr@pm.me>
Subject: [PATCH v2] add: support pre-add hook
Date: Wed, 11 Feb 2026 15:05:12 +0000 [thread overview]
Message-ID: <pull.2045.v2.git.1770822312474.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2045.git.1770737573475.gitgitgadget@gmail.com>
From: Chandra Kethi-Reddy <chandrakr@pm.me>
"git add" has no hook that lets users inspect what is about to be
staged. Users who want to reject certain paths or content must
wrap the command in a shell alias or wait for pre-commit, which
fires after staging is already done and objects may already be in
the object database.
Introduce a "pre-add" hook that runs after "git add" computes the
new index state but before committing it to disk. The hook
receives two arguments:
$1 -- path to a temporary copy of the index before this "git add"
$2 -- path to the lockfile containing the proposed index
$1 on first add can be a non-existent path representing an empty
index.
Hook authors can inspect the computed result with ordinary tools:
GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD
without needing to interpret pathspec or mode flags like "-u" or
"--renormalize" -- the proposed index already reflects their effect.
The implementation creates a temporary copy of the index via the
tempfile API when find_hook("pre-add") reports a hook is present,
then lets all staging proceed normally. At the finish label,
write_locked_index() writes the proposed index to the lockfile
without COMMIT_LOCK. If the hook approves, commit_lock_file()
atomically replaces the index. If the hook rejects,
rollback_lock_file() discards the lockfile and the original index
is left unchanged. When no hook is installed, the existing
write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still
taken.
The hook is bypassed with "--no-verify" and is not invoked for
--interactive, --patch, --edit, or --dry-run, nor by "git commit -a"
which stages through its own code path.
Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson
and Makefile lists.
Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>
---
add: support pre-add hook
Summary
=======
* v2 reworks pre-add to follow Junio's suggested architecture: snapshot
original index, compute staging normally, write proposed index to
lockfile, run hook with ($1 original, $2 proposed), then
commit_lock_file() or rollback_lock_file().
* Hook authors now inspect computed results directly with
GIT_INDEX_FILE="$1" / GIT_INDEX_FILE="$2" instead of trying to
emulate pathspec/mode behavior.
* Added tests for two-argument contract, original-vs-proposed
comparison, explicit rollback behavior on hook rejection, and example
policies (filename/content rejection).
Notes
=====
* This design intentionally trades ODB prevention for correctness of
hook inputs: blobs may already be written to object storage when the
hook runs, but hook rejection still leaves the on-disk index
unchanged.
* Conflicts with ar/parallel-hooks on seen:
RUN_HOOKS_OPT_INIT → RUN_HOOKS_OPT_INIT_SERIAL.
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2045%2Fshatachandra%2Fpre-add-hooks-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2045/shatachandra/pre-add-hooks-v2
Pull-Request: https://github.com/gitgitgadget/git/pull/2045
Range-diff vs v1:
1: 964bec5b9ea ! 1: 10244150e24 add: support pre-add hook
@@ Metadata
## Commit message ##
add: support pre-add hook
- git has no hook that fires during 'git add'. Users who want to
- validate files before staging must wrap 'git add' in a shell alias
- or wait for pre-commit, which fires after staging is already done.
+ "git add" has no hook that lets users inspect what is about to be
+ staged. Users who want to reject certain paths or content must
+ wrap the command in a shell alias or wait for pre-commit, which
+ fires after staging is already done and objects may already be in
+ the object database.
- Add a pre-add hook that runs after pathspec validation and before
- any files are staged. If the hook exits non-zero, 'git add' aborts
- without modifying the index. The hook receives GIT_INDEX_FILE in
- its environment, following the same convention as pre-commit.
+ Introduce a "pre-add" hook that runs after "git add" computes the
+ new index state but before committing it to disk. The hook
+ receives two arguments:
- The hook is bypassed with '--no-verify' (long flag only, since '-n'
- is already '--dry-run' in 'git add'). It is not invoked for
- --interactive, --patch, --edit, or --dry-run modes, nor by
- 'git commit -a' which stages files through its own code path in
- builtin/commit.c.
+ $1 -- path to a temporary copy of the index before this "git add"
+ $2 -- path to the lockfile containing the proposed index
- The implementation calls run_hooks_opt() directly rather than the
- run_commit_hook() wrapper, which sets GIT_EDITOR=: and is not
- relevant for 'git add'. When no hook is installed, there is no
- performance impact.
+ $1 on first add can be a non-existent path representing an empty
+ index.
- Disclosure: developed with guidance from Claude Code (Anthropic)
- and Codex CLI (OpenAI) for development, review and standards
- compliance. The contributor handtyped and reviewed all tests, code,
- and documentation.
+ Hook authors can inspect the computed result with ordinary tools:
+
+ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD
+
+ without needing to interpret pathspec or mode flags like "-u" or
+ "--renormalize" -- the proposed index already reflects their effect.
+
+ The implementation creates a temporary copy of the index via the
+ tempfile API when find_hook("pre-add") reports a hook is present,
+ then lets all staging proceed normally. At the finish label,
+ write_locked_index() writes the proposed index to the lockfile
+ without COMMIT_LOCK. If the hook approves, commit_lock_file()
+ atomically replaces the index. If the hook rejects,
+ rollback_lock_file() discards the lockfile and the original index
+ is left unchanged. When no hook is installed, the existing
+ write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is still
+ taken.
+
+ The hook is bypassed with "--no-verify" and is not invoked for
+ --interactive, --patch, --edit, or --dry-run, nor by "git commit -a"
+ which stages through its own code path.
+
+ Register t3706-pre-add-hook.sh in t/meson.build to synchronize Meson
+ and Makefile lists.
Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>
@@ Documentation/git-add.adoc: use the `--force` option to add ignored files. If yo
filename of an ignored file, `git add` will fail with a list of ignored
files. Otherwise it will silently ignore the file.
-+A pre-add hook can be run to inspect or reject the add operation before
-+it stages files. See linkgit:githooks[5] for details.
++A pre-add hook can be run to inspect or reject the proposed index update
++after `git add` computes staging and writes it to the index lockfile,
++but before writing it to the final index. See linkgit:githooks[5].
+
Please see linkgit:git-commit[1] for alternative ways to add content to a
commit.
@@ Documentation/githooks.adoc: and is invoked after the patch is applied and a com
+~~~~~~~
+
+This hook is invoked by linkgit:git-add[1], and can be bypassed with the
-+`--no-verify` option. This hook is not invoked for `--interactive`, `--patch`,
-+`--edit`, or `--dry-run`. It takes no parameters, and is invoked after pathspec
-+validation and before any files are staged. Exiting with a non-zero status
-+from this script causes the `git add` command to abort without modifying the
-+index.
++`--no-verify` option. It is not invoked for `--interactive`, `--patch`,
++`--edit`, or `--dry-run`.
+
-+This hook is invoked with the environment variable `GIT_INDEX_FILE`
-+which points to the index file. This allows the hook to inspect what
-+files would be staged before the operation proceeds.
++It takes two parameters: the path to a copy of the index before this
++invocation of `git add`, and the path to the lockfile containing the
++proposed index after staging. It does not read from standard input.
++If no index exists yet, the first parameter names a path that does not
++exist and should be treated as an empty index. No special environment
++variables are set. The hook is invoked after the index has been updated
++in memory and written to the lockfile, but before it is committed to the
++final location.
+
-+This hook is not invoked by `git commit -a` or `git commit --include` which
-+still can run the pre-commit hook, providing a control point at commit time.
++Exiting with a non-zero status causes `git add` to abort and leaves the
++index unchanged. Exiting with zero status causes the staged changes to
++take effect.
++
++This hook can be used to prevent staging of files based on names, content,
++or sizes (e.g., to block `.env` files, secret keys, or large files).
++
++This hook is not invoked by `git commit -a` or `git commit --include`
++which still can run the pre-commit hook, providing a control point at
++commit time.
+
pre-commit
~~~~~~~~~~
@@ builtin/add.c
#include "submodule.h"
#include "add-interactive.h"
+#include "hook.h"
++#include "copy.h"
static const char * const builtin_add_usage[] = {
N_("git add [<options>] [--] <pathspec>..."),
@@ builtin/add.c: static struct option builtin_add_options[] = {
OPT_BOOL(0, "sparse", &include_sparse, N_("allow updating entries outside of the sparse-checkout cone")),
OPT_STRING(0, "chmod", &chmod_arg, "(+|-)x",
N_("override the executable bit of the listed files")),
+@@ builtin/add.c: int cmd_add(int argc,
+ char *ps_matched = NULL;
+ struct lock_file lock_file = LOCK_INIT;
+ struct odb_transaction *transaction;
++ int run_pre_add = 0;
++ struct tempfile *orig_index = NULL;
++ char *orig_index_path = NULL;
+
+ repo_config(repo, add_config, NULL);
+
@@ builtin/add.c: int cmd_add(int argc,
string_list_clear(&only_match_skip_worktree, 0);
}
-+ if (!show_only && !no_verify) {
++ if (!show_only && !no_verify && find_hook(repo, "pre-add")) {
++ int fd_in, status;
++ const char *index_file = repo_get_index_file(repo);
++ char *template;
++
++ run_pre_add = 1;
++ template = xstrfmt("%s.pre-add.XXXXXX", index_file);
++ orig_index = xmks_tempfile(template);
++ free(template);
++
++ fd_in = open(index_file, O_RDONLY);
++ if (fd_in >= 0) {
++ status = copy_fd(fd_in, get_tempfile_fd(orig_index));
++ if (close(fd_in))
++ die_errno(_("unable to close index for pre-add hook"));
++ if (close_tempfile_gently(orig_index))
++ die_errno(_("unable to close temporary index copy"));
++ if (status < 0)
++ die(_("failed to copy index for pre-add hook"));
++ } else if (errno == ENOENT) {
++ orig_index_path = xstrdup(get_tempfile_path(orig_index));
++ if (delete_tempfile(&orig_index))
++ die_errno(_("unable to remove temporary index copy"));
++ } else {
++ die_errno(_("unable to open index for pre-add hook"));
++ }
++ }
++
+ transaction = odb_transaction_begin(repo->objects);
+
+ ps_matched = xcalloc(pathspec.nr, 1);
+@@ builtin/add.c: int cmd_add(int argc,
+ include_sparse, flags);
+
+ if (take_worktree_changes && !add_renormalize && !ignore_add_errors &&
+- report_path_error(ps_matched, &pathspec))
++ report_path_error(ps_matched, &pathspec)) {
++ if (orig_index)
++ delete_tempfile(&orig_index);
++ free(orig_index_path);
+ exit(128);
++ }
+
+ if (add_new_files)
+ exit_status |= add_files(repo, &dir, flags);
+@@ builtin/add.c: int cmd_add(int argc,
+ odb_transaction_commit(transaction);
+
+ finish:
+- if (write_locked_index(repo->index, &lock_file,
+- COMMIT_LOCK | SKIP_IF_UNCHANGED))
+- die(_("unable to write new index file"));
++ if (run_pre_add && !exit_status && repo->index->cache_changed) {
+ struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
+
-+ strvec_pushf(&opt.env, "GIT_INDEX_FILE=%s",
-+ repo_get_index_file(repo));
++ if (write_locked_index(repo->index, &lock_file, 0))
++ die(_("unable to write new index file"));
++
++ strvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :
++ orig_index_path);
++ strvec_push(&opt.args, get_lock_file_path(&lock_file));
+ if (run_hooks_opt(repo, "pre-add", &opt)) {
++ rollback_lock_file(&lock_file); /* hook rejected */
+ exit_status = 1;
-+ goto finish;
++ } else {
++ if (commit_lock_file(&lock_file)) /* hook approved */
++ die(_("unable to write new index file"));
+ }
++ } else {
++ if (write_locked_index(repo->index, &lock_file,
++ COMMIT_LOCK | SKIP_IF_UNCHANGED))
++ die(_("unable to write new index file"));
+ }
+
- transaction = odb_transaction_begin(repo->objects);
++ delete_tempfile(&orig_index);
++ free(orig_index_path);
- ps_matched = xcalloc(pathspec.nr, 1);
+ free(ps_matched);
+ dir_clear(&dir);
+
+ ## t/meson.build ##
+@@ t/meson.build: integration_tests = [
+ 't3703-add-magic-pathspec.sh',
+ 't3704-add-pathspec-file.sh',
+ 't3705-add-sparse-checkout.sh',
++ 't3706-pre-add-hook.sh',
+ 't3800-mktag.sh',
+ 't3900-i18n-commit.sh',
+ 't3901-i18n-patch.sh',
## t/t3706-pre-add-hook.sh (new) ##
@@
@@ t/t3706-pre-add-hook.sh (new)
+ test_path_is_missing actual
+'
+
-+test_expect_success 'hook receives GIT_INDEX_FILE environment variable' '
-+ test_when_finished "rm -f actual expected" &&
-+ echo "hook-saw-env" >expected &&
++test_expect_success 'hook receives original and proposed index as arguments' '
++ test_when_finished "rm -f tracked expected hook-ran" &&
++ echo "initial" >tracked &&
++ git add tracked &&
++ git commit -m "initial" &&
+ test_hook pre-add <<-\EOF &&
-+ if test -z "$GIT_INDEX_FILE"
-+ then
-+ echo hook-missing-env >>actual
-+ else
-+ echo hook-saw-env >>actual
-+ fi
++ test $# -eq 2 &&
++ test -f "$1" &&
++ test -f "$2" &&
++ echo pass >hook-ran
+ EOF
+
-+ echo content >file &&
-+ git add file &&
-+ test_cmp expected actual
++ echo "modified" >tracked &&
++ git add tracked &&
++ echo pass >expected &&
++ test_cmp expected hook-ran
++'
++
++test_expect_success 'hook handles first add with no existing index' '
++ test_when_finished "rm -rf no-index" &&
++ test_create_repo no-index &&
++ echo ok >no-index/expected &&
++ test_hook -C no-index pre-add <<-\EOF &&
++ test $# -eq 2 &&
++ test ! -e "$1" &&
++ test -f "$2" &&
++ echo ok >hook-ran
++ EOF
++
++ echo first >no-index/file &&
++ git -C no-index add file &&
++ test_cmp no-index/expected no-index/hook-ran
+'
+
-+test_expect_success 'with --dry-run (show-only) the hook is not invoked' '
++test_expect_success 'hook is not invoked with --dry-run (show-only)' '
+ test_when_finished "rm -f actual" &&
+ test_hook pre-add <<-\EOF &&
+ echo should-not-run >>actual
@@ t/t3706-pre-add-hook.sh (new)
+ test_cmp expected actual
+'
+
++test_expect_success 'hook can compare original and proposed index' '
++ test_when_finished "rm -f old-raw new-raw old-list new-list \
++ expected-old expected-new" &&
++ echo "initial" >file1 &&
++ echo "initial" >file2 &&
++ git add file1 file2 &&
++ git commit -m "initial" &&
++ echo "staged-before" >file1 &&
++ git add file1 &&
++ test_hook pre-add <<-\EOF &&
++ GIT_INDEX_FILE="$1" git diff --cached --name-only HEAD >old-raw &&
++ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-raw &&
++ sort old-raw >old-list &&
++ sort new-raw >new-list
++ EOF
++
++ echo "modified" >file2 &&
++ git add file2 &&
++ echo file1 >expected-old &&
++ printf "%s\n" file1 file2 >expected-new &&
++ test_cmp expected-old old-list &&
++ test_cmp expected-new new-list
++'
++
++test_expect_success 'hook rejection rolls back index unchanged' '
++ test_when_finished "rm -f file before after old-raw new-raw \
++ old-list new-list expected-old expected-new" &&
++ echo "initial" >file &&
++ git add file &&
++ git commit -m "initial" &&
++ git diff --cached --name-only HEAD >before &&
++ test_hook pre-add <<-\EOF &&
++ GIT_INDEX_FILE="$1" git diff --cached --name-only HEAD >old-raw &&
++ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-raw &&
++ sort old-raw >old-list &&
++ sort new-raw >new-list &&
++ exit 1
++ EOF
++
++ echo "modified" >file &&
++ test_must_fail git add file &&
++ git diff --cached --name-only HEAD >after &&
++ test_cmp before after &&
++ : >expected-old &&
++ echo file >expected-new &&
++ test_cmp expected-old old-list &&
++ test_cmp expected-new new-list
++'
++
++test_expect_success 'hook example: block .env files' '
++ test_when_finished "rm -f .env safe.txt new-paths" &&
++ echo "initial" >base &&
++ git add base &&
++ git commit -m "initial" &&
++ test_hook pre-add <<-\EOF &&
++ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-paths &&
++ while read path
++ do
++ case "$path" in
++ *.env|.env)
++ echo "error: $path must not be staged" >&2
++ exit 1
++ ;;
++ esac
++ done <new-paths
++ EOF
++
++ echo "DB_PASS=secret" >.env &&
++ test_must_fail git add .env &&
++ echo "safe content" >safe.txt &&
++ git add safe.txt
++'
++
++test_expect_success 'hook example: block secrets in content' '
++ test_when_finished "rm -f config.txt secret" &&
++ echo "initial" >config.txt &&
++ git add config.txt &&
++ git commit -m "initial" &&
++ test_hook pre-add <<-\EOF &&
++ GIT_INDEX_FILE="$2" git diff --cached HEAD >secret &&
++ if grep -qE "(API_KEY|SECRET_KEY|PRIVATE_KEY)=" secret
++ then
++ echo "error: staged content contains secrets" >&2
++ exit 1
++ fi
++ EOF
++
++ echo "API_KEY=sksksk-live-12345" >config.txt &&
++ test_must_fail git add config.txt &&
++ echo "LOG_LEVEL=debug" >config.txt &&
++ git add config.txt
++'
++
+test_done
Documentation/git-add.adoc | 11 +-
Documentation/githooks.adoc | 27 +++++
builtin/add.c | 68 ++++++++++-
t/meson.build | 1 +
t/t3706-pre-add-hook.sh | 227 ++++++++++++++++++++++++++++++++++++
5 files changed, 329 insertions(+), 5 deletions(-)
create mode 100755 t/t3706-pre-add-hook.sh
diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc
index 6192daeb03..c864ce272d 100644
--- a/Documentation/git-add.adoc
+++ b/Documentation/git-add.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
[synopsis]
git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]
[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]
- [--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]
+ [--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]
[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
[--] [<pathspec>...]
@@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact
filename of an ignored file, `git add` will fail with a list of ignored
files. Otherwise it will silently ignore the file.
+A pre-add hook can be run to inspect or reject the proposed index update
+after `git add` computes staging and writes it to the index lockfile,
+but before writing it to the final index. See linkgit:githooks[5].
+
Please see linkgit:git-commit[1] for alternative ways to add content to a
commit.
@@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.
Don't add the file(s), but only refresh their stat()
information in the index.
+`--no-verify`::
+ Bypass the pre-add hook if it exists. See linkgit:githooks[5] for
+ more information about hooks.
+
`--ignore-errors`::
If some files could not be added because of errors indexing
them, do not abort the operation, but continue adding the
@@ -451,6 +459,7 @@ linkgit:git-reset[1]
linkgit:git-mv[1]
linkgit:git-commit[1]
linkgit:git-update-index[1]
+linkgit:githooks[5]
GIT
---
diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc
index 056553788d..7ef8718112 100644
--- a/Documentation/githooks.adoc
+++ b/Documentation/githooks.adoc
@@ -94,6 +94,33 @@ and is invoked after the patch is applied and a commit is made.
This hook is meant primarily for notification, and cannot affect
the outcome of `git am`.
+pre-add
+~~~~~~~
+
+This hook is invoked by linkgit:git-add[1], and can be bypassed with the
+`--no-verify` option. It is not invoked for `--interactive`, `--patch`,
+`--edit`, or `--dry-run`.
+
+It takes two parameters: the path to a copy of the index before this
+invocation of `git add`, and the path to the lockfile containing the
+proposed index after staging. It does not read from standard input.
+If no index exists yet, the first parameter names a path that does not
+exist and should be treated as an empty index. No special environment
+variables are set. The hook is invoked after the index has been updated
+in memory and written to the lockfile, but before it is committed to the
+final location.
+
+Exiting with a non-zero status causes `git add` to abort and leaves the
+index unchanged. Exiting with zero status causes the staged changes to
+take effect.
+
+This hook can be used to prevent staging of files based on names, content,
+or sizes (e.g., to block `.env` files, secret keys, or large files).
+
+This hook is not invoked by `git commit -a` or `git commit --include`
+which still can run the pre-commit hook, providing a control point at
+commit time.
+
pre-commit
~~~~~~~~~~
diff --git a/builtin/add.c b/builtin/add.c
index 32709794b3..735c9a53fd 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -25,6 +25,8 @@
#include "strvec.h"
#include "submodule.h"
#include "add-interactive.h"
+#include "hook.h"
+#include "copy.h"
static const char * const builtin_add_usage[] = {
N_("git add [<options>] [--] <pathspec>..."),
@@ -36,6 +38,7 @@ static int take_worktree_changes;
static int add_renormalize;
static int pathspec_file_nul;
static int include_sparse;
+static int no_verify;
static const char *pathspec_from_file;
static int chmod_pathspec(struct repository *repo,
@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {
OPT_BOOL( 0 , "refresh", &refresh_only, N_("don't add, only refresh the index")),
OPT_BOOL( 0 , "ignore-errors", &ignore_add_errors, N_("just skip files which cannot be added because of errors")),
OPT_BOOL( 0 , "ignore-missing", &ignore_missing, N_("check if - even missing - files are ignored in dry run")),
+ OPT_BOOL( 0 , "no-verify", &no_verify, N_("bypass pre-add hook")),
OPT_BOOL(0, "sparse", &include_sparse, N_("allow updating entries outside of the sparse-checkout cone")),
OPT_STRING(0, "chmod", &chmod_arg, "(+|-)x",
N_("override the executable bit of the listed files")),
@@ -391,6 +395,9 @@ int cmd_add(int argc,
char *ps_matched = NULL;
struct lock_file lock_file = LOCK_INIT;
struct odb_transaction *transaction;
+ int run_pre_add = 0;
+ struct tempfile *orig_index = NULL;
+ char *orig_index_path = NULL;
repo_config(repo, add_config, NULL);
@@ -576,6 +583,34 @@ int cmd_add(int argc,
string_list_clear(&only_match_skip_worktree, 0);
}
+ if (!show_only && !no_verify && find_hook(repo, "pre-add")) {
+ int fd_in, status;
+ const char *index_file = repo_get_index_file(repo);
+ char *template;
+
+ run_pre_add = 1;
+ template = xstrfmt("%s.pre-add.XXXXXX", index_file);
+ orig_index = xmks_tempfile(template);
+ free(template);
+
+ fd_in = open(index_file, O_RDONLY);
+ if (fd_in >= 0) {
+ status = copy_fd(fd_in, get_tempfile_fd(orig_index));
+ if (close(fd_in))
+ die_errno(_("unable to close index for pre-add hook"));
+ if (close_tempfile_gently(orig_index))
+ die_errno(_("unable to close temporary index copy"));
+ if (status < 0)
+ die(_("failed to copy index for pre-add hook"));
+ } else if (errno == ENOENT) {
+ orig_index_path = xstrdup(get_tempfile_path(orig_index));
+ if (delete_tempfile(&orig_index))
+ die_errno(_("unable to remove temporary index copy"));
+ } else {
+ die_errno(_("unable to open index for pre-add hook"));
+ }
+ }
+
transaction = odb_transaction_begin(repo->objects);
ps_matched = xcalloc(pathspec.nr, 1);
@@ -587,8 +622,12 @@ int cmd_add(int argc,
include_sparse, flags);
if (take_worktree_changes && !add_renormalize && !ignore_add_errors &&
- report_path_error(ps_matched, &pathspec))
+ report_path_error(ps_matched, &pathspec)) {
+ if (orig_index)
+ delete_tempfile(&orig_index);
+ free(orig_index_path);
exit(128);
+ }
if (add_new_files)
exit_status |= add_files(repo, &dir, flags);
@@ -598,9 +637,30 @@ int cmd_add(int argc,
odb_transaction_commit(transaction);
finish:
- if (write_locked_index(repo->index, &lock_file,
- COMMIT_LOCK | SKIP_IF_UNCHANGED))
- die(_("unable to write new index file"));
+ if (run_pre_add && !exit_status && repo->index->cache_changed) {
+ struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
+
+ if (write_locked_index(repo->index, &lock_file, 0))
+ die(_("unable to write new index file"));
+
+ strvec_push(&opt.args, orig_index ? get_tempfile_path(orig_index) :
+ orig_index_path);
+ strvec_push(&opt.args, get_lock_file_path(&lock_file));
+ if (run_hooks_opt(repo, "pre-add", &opt)) {
+ rollback_lock_file(&lock_file); /* hook rejected */
+ exit_status = 1;
+ } else {
+ if (commit_lock_file(&lock_file)) /* hook approved */
+ die(_("unable to write new index file"));
+ }
+ } else {
+ if (write_locked_index(repo->index, &lock_file,
+ COMMIT_LOCK | SKIP_IF_UNCHANGED))
+ die(_("unable to write new index file"));
+ }
+
+ delete_tempfile(&orig_index);
+ free(orig_index_path);
free(ps_matched);
dir_clear(&dir);
diff --git a/t/meson.build b/t/meson.build
index 459c52a489..d518596fcb 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -412,6 +412,7 @@ integration_tests = [
't3703-add-magic-pathspec.sh',
't3704-add-pathspec-file.sh',
't3705-add-sparse-checkout.sh',
+ 't3706-pre-add-hook.sh',
't3800-mktag.sh',
't3900-i18n-commit.sh',
't3901-i18n-patch.sh',
diff --git a/t/t3706-pre-add-hook.sh b/t/t3706-pre-add-hook.sh
new file mode 100755
index 0000000000..5ff7161f9d
--- /dev/null
+++ b/t/t3706-pre-add-hook.sh
@@ -0,0 +1,227 @@
+#!/bin/sh
+
+test_description='pre-add hook tests
+
+These tests run git add with and without pre-add hooks to ensure functionality. Largely derived from t7503 (pre-commit and pre-merge-commit hooks) and t5571 (pre-push hooks).'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'with no hook' '
+ test_when_finished "rm -f actual" &&
+ echo content >file &&
+ git add file &&
+ test_path_is_missing actual
+'
+
+test_expect_success POSIXPERM 'with non-executable hook' '
+ test_when_finished "rm -f actual" &&
+ test_hook pre-add <<-\EOF &&
+ echo should-not-run >>actual
+ exit 1
+ EOF
+ chmod -x .git/hooks/pre-add &&
+
+ echo content >file &&
+ git add file &&
+ test_path_is_missing actual
+'
+
+test_expect_success '--no-verify with no hook' '
+ echo content >file &&
+ git add --no-verify file &&
+ test_path_is_missing actual
+'
+
+test_expect_success 'with succeeding hook' '
+ test_when_finished "rm -f actual expected" &&
+ echo "pre-add" >expected &&
+ test_hook pre-add <<-\EOF &&
+ echo pre-add >>actual
+ EOF
+
+ echo content >file &&
+ git add file &&
+ test_cmp expected actual
+'
+
+test_expect_success 'with failing hook' '
+ test_when_finished "rm -f actual" &&
+ test_hook pre-add <<-\EOF &&
+ echo pre-add-rejected >>actual
+ exit 1
+ EOF
+
+ echo content >file &&
+ test_must_fail git add file
+'
+
+test_expect_success '--no-verify with failing hook' '
+ test_when_finished "rm -f actual" &&
+ test_hook pre-add <<-\EOF &&
+ echo should-not-run >>actual
+ exit 1
+ EOF
+
+ echo content >file &&
+ git add --no-verify file &&
+ test_path_is_missing actual
+'
+
+test_expect_success 'hook receives original and proposed index as arguments' '
+ test_when_finished "rm -f tracked expected hook-ran" &&
+ echo "initial" >tracked &&
+ git add tracked &&
+ git commit -m "initial" &&
+ test_hook pre-add <<-\EOF &&
+ test $# -eq 2 &&
+ test -f "$1" &&
+ test -f "$2" &&
+ echo pass >hook-ran
+ EOF
+
+ echo "modified" >tracked &&
+ git add tracked &&
+ echo pass >expected &&
+ test_cmp expected hook-ran
+'
+
+test_expect_success 'hook handles first add with no existing index' '
+ test_when_finished "rm -rf no-index" &&
+ test_create_repo no-index &&
+ echo ok >no-index/expected &&
+ test_hook -C no-index pre-add <<-\EOF &&
+ test $# -eq 2 &&
+ test ! -e "$1" &&
+ test -f "$2" &&
+ echo ok >hook-ran
+ EOF
+
+ echo first >no-index/file &&
+ git -C no-index add file &&
+ test_cmp no-index/expected no-index/hook-ran
+'
+
+test_expect_success 'hook is not invoked with --dry-run (show-only)' '
+ test_when_finished "rm -f actual" &&
+ test_hook pre-add <<-\EOF &&
+ echo should-not-run >>actual
+ exit 1
+ EOF
+
+ echo content >file &&
+ git add --dry-run file &&
+ test_path_is_missing actual
+'
+
+test_expect_success 'hook is invoked with git add -u' '
+ test_when_finished "rm -f actual expected file" &&
+ echo "initial" >file &&
+ git add file &&
+ git commit -m "initial" &&
+ echo "pre-add" >expected &&
+ test_hook pre-add <<-\EOF &&
+ echo pre-add >>actual
+ EOF
+
+ echo modified >file &&
+ git add -u &&
+ test_cmp expected actual
+'
+
+test_expect_success 'hook can compare original and proposed index' '
+ test_when_finished "rm -f old-raw new-raw old-list new-list \
+ expected-old expected-new" &&
+ echo "initial" >file1 &&
+ echo "initial" >file2 &&
+ git add file1 file2 &&
+ git commit -m "initial" &&
+ echo "staged-before" >file1 &&
+ git add file1 &&
+ test_hook pre-add <<-\EOF &&
+ GIT_INDEX_FILE="$1" git diff --cached --name-only HEAD >old-raw &&
+ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-raw &&
+ sort old-raw >old-list &&
+ sort new-raw >new-list
+ EOF
+
+ echo "modified" >file2 &&
+ git add file2 &&
+ echo file1 >expected-old &&
+ printf "%s\n" file1 file2 >expected-new &&
+ test_cmp expected-old old-list &&
+ test_cmp expected-new new-list
+'
+
+test_expect_success 'hook rejection rolls back index unchanged' '
+ test_when_finished "rm -f file before after old-raw new-raw \
+ old-list new-list expected-old expected-new" &&
+ echo "initial" >file &&
+ git add file &&
+ git commit -m "initial" &&
+ git diff --cached --name-only HEAD >before &&
+ test_hook pre-add <<-\EOF &&
+ GIT_INDEX_FILE="$1" git diff --cached --name-only HEAD >old-raw &&
+ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-raw &&
+ sort old-raw >old-list &&
+ sort new-raw >new-list &&
+ exit 1
+ EOF
+
+ echo "modified" >file &&
+ test_must_fail git add file &&
+ git diff --cached --name-only HEAD >after &&
+ test_cmp before after &&
+ : >expected-old &&
+ echo file >expected-new &&
+ test_cmp expected-old old-list &&
+ test_cmp expected-new new-list
+'
+
+test_expect_success 'hook example: block .env files' '
+ test_when_finished "rm -f .env safe.txt new-paths" &&
+ echo "initial" >base &&
+ git add base &&
+ git commit -m "initial" &&
+ test_hook pre-add <<-\EOF &&
+ GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD >new-paths &&
+ while read path
+ do
+ case "$path" in
+ *.env|.env)
+ echo "error: $path must not be staged" >&2
+ exit 1
+ ;;
+ esac
+ done <new-paths
+ EOF
+
+ echo "DB_PASS=secret" >.env &&
+ test_must_fail git add .env &&
+ echo "safe content" >safe.txt &&
+ git add safe.txt
+'
+
+test_expect_success 'hook example: block secrets in content' '
+ test_when_finished "rm -f config.txt secret" &&
+ echo "initial" >config.txt &&
+ git add config.txt &&
+ git commit -m "initial" &&
+ test_hook pre-add <<-\EOF &&
+ GIT_INDEX_FILE="$2" git diff --cached HEAD >secret &&
+ if grep -qE "(API_KEY|SECRET_KEY|PRIVATE_KEY)=" secret
+ then
+ echo "error: staged content contains secrets" >&2
+ exit 1
+ fi
+ EOF
+
+ echo "API_KEY=sksksk-live-12345" >config.txt &&
+ test_must_fail git add config.txt &&
+ echo "LOG_LEVEL=debug" >config.txt &&
+ git add config.txt
+'
+
+test_done
base-commit: b2826b52eb7caff9f4ed6e85ec45e338bf02ad09
--
gitgitgadget
next prev parent reply other threads:[~2026-02-11 15:05 UTC|newest]
Thread overview: 26+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-02-10 15:32 [PATCH] add: support pre-add hook Chandra Kethi-Reddy via GitGitGadget
2026-02-10 18:16 ` Junio C Hamano
2026-02-10 19:00 ` Junio C Hamano
2026-02-11 15:16 ` [PATCH] " Chandra
2026-02-11 15:05 ` Chandra Kethi-Reddy via GitGitGadget [this message]
2026-02-11 19:50 ` [PATCH v2] " Junio C Hamano
2026-02-11 21:11 ` Chandra
2026-02-11 21:24 ` Junio C Hamano
2026-02-11 21:54 ` Chandra
2026-02-25 2:15 ` [PATCH v3] " Chandra
2026-02-27 5:54 ` Chandra Kethi-Reddy via GitGitGadget
2026-03-03 23:06 ` Junio C Hamano
2026-03-04 9:49 ` Ben Knoble
2026-03-05 10:47 ` Phillip Wood
2026-03-05 11:40 ` [PATCH v4] " Chandra
2026-03-05 14:48 ` [PATCH v3] " Junio C Hamano
2026-03-05 11:36 ` [PATCH v4] " Chandra Kethi-Reddy via GitGitGadget
2026-03-05 12:03 ` Adrian Ratiu
2026-03-05 12:37 ` Chandra
2026-03-05 12:37 ` [PATCH v5] " Chandra Kethi-Reddy via GitGitGadget
2026-03-05 13:41 ` Adrian Ratiu
2026-03-05 13:46 ` Chandra
2026-03-05 19:23 ` Junio C Hamano
2026-03-06 2:20 ` Chandra
2026-03-13 14:39 ` Phillip Wood
2026-03-05 14:37 ` Phillip Wood
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2045.v2.git.1770822312474.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=chandrakr@pm.me \
--cc=git@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox