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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.