Skip to content

Commit d0fb5f9

Browse files
committed
add: support pre-add hook
"git add" has no hook that lets users inspect what is about to be staged. Users who want to reject certain paths or content must wrap the command in a shell alias or wait for pre-commit, which fires too late to prevent staging. Introduce a "pre-add" hook that runs after "git add" computes the new index state but before committing it to disk. The hook receives two positional arguments: $1 -- index path used by this invocation (may not exist yet) $2 -- lockfile path containing proposed staged index state While the lockfile is active the current index path remains readable and unchanged, so a seperate copy is unnecessary. Hook authors can inspect the computed result with ordinary tools: GIT_INDEX_FILE="$2" git diff --cached --name-only HEAD without needing to interpret pathspec or mode flags as the proposed index already reflects their effect. At the finish label, write_locked_index() writes the proposed index to the lockfile without COMMIT_LOCK so commit_lock_file() can be called seperately after the hook runs. However, do_write_locked_index() unconditionally fires post-index-change after every write, and the existing test suite (t7113) asserts that index.lock does not exist when that hook fires. Tying the hook to COMMIT_LOCK would suppress it for other callers that depend on it after a non-committed write (e.g., prepare_to_commit() in builtin/commit.c). A new SKIP_INDEX_CHANGE_HOOK flag lets builtin/add.c suppress the automatic notification on just this call, then emit post-index-change manually after commit_lock_file() publishes the new index. If the hook rejects, rollback_lock_file() discards the lockfile and the original index is left unchanged. When no hook is installed the existing write_locked_index(COMMIT_LOCK | SKIP_IF_UNCHANGED) path is taken. The hook gate checks cache_changed regardless of exit_status so that mixed-result adds (e.g., a tracked modification combined with an ignored path) still run the hook when index content changes. The hook is bypassed with "--no-verify" and is not invoked for --interactive, --patch, --edit, or --dry-run, nor by "git commit -a" which stages through its own code path. Signed-off-by: Chandra Kethi-Reddy <chandrakr@pm.me>
1 parent 7c02d39 commit d0fb5f9

7 files changed

Lines changed: 381 additions & 11 deletions

File tree

Documentation/git-add.adoc

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ SYNOPSIS
1010
[synopsis]
1111
git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]
1212
[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]
13-
[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]
13+
[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize] [--no-verify]
1414
[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
1515
[--] [<pathspec>...]
1616

@@ -42,6 +42,10 @@ use the `--force` option to add ignored files. If you specify the exact
4242
filename of an ignored file, `git add` will fail with a list of ignored
4343
files. Otherwise it will silently ignore the file.
4444

45+
A `pre-add` hook can be run to inspect or reject the proposed index update
46+
after `git add` computes staging and writes it to the index lockfile,
47+
but before writing it to the final index. See linkgit:githooks[5].
48+
4549
Please see linkgit:git-commit[1] for alternative ways to add content to a
4650
commit.
4751

@@ -163,6 +167,10 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.
163167
Don't add the file(s), but only refresh their stat()
164168
information in the index.
165169
170+
`--no-verify`::
171+
Bypass the `pre-add` hook if it exists. See linkgit:githooks[5] for
172+
more information about hooks.
173+
166174
`--ignore-errors`::
167175
If some files could not be added because of errors indexing
168176
them, do not abort the operation, but continue adding the
@@ -451,6 +459,7 @@ linkgit:git-reset[1]
451459
linkgit:git-mv[1]
452460
linkgit:git-commit[1]
453461
linkgit:git-update-index[1]
462+
linkgit:githooks[5]
454463

455464
GIT
456465
---

Documentation/githooks.adoc

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,36 @@ and is invoked after the patch is applied and a commit is made.
9494
This hook is meant primarily for notification, and cannot affect
9595
the outcome of `git am`.
9696
97+
pre-add
98+
~~~~~~~
99+
100+
This hook is invoked by linkgit:git-add[1], and can be bypassed with the
101+
`--no-verify` option. It is not invoked for `--interactive`, `--patch`,
102+
`--edit`, or `--dry-run`.
103+
104+
It takes two parameters: the path to the index file for this invocation
105+
of `git add`, and the path to the lockfile containing the proposed
106+
index after staging. It does not read from standard input. If no index
107+
exists yet, the first parameter names a path that does not exist and
108+
should be treated as an empty index.
109+
110+
The hook is invoked after the index has been updated in memory and
111+
written to the lockfile, but before it is committed to the final index
112+
path. Exiting with a non-zero status causes `git add` to reject the
113+
proposed state, roll back the lockfile, and leave the index unchanged.
114+
Exiting with zero status allows the index update to be committed.
115+
116+
Git does not set `GIT_INDEX_FILE` for this hook. Hook authors may
117+
set `GIT_INDEX_FILE="$1"` to inspect current index state and
118+
`GIT_INDEX_FILE="$2"` to inspect proposed index state.
119+
120+
This hook can be used to prevent staging of files based on names, content,
121+
or sizes (e.g., to block `.env` files, secret keys, or large files).
122+
123+
This hook is not invoked by `git commit -a` or `git commit --include`
124+
which still can run the `pre-commit` hook, providing a control point at
125+
commit time.
126+
97127
pre-commit
98128
~~~~~~~~~~
99129

builtin/add.c

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
#include "strvec.h"
2626
#include "submodule.h"
2727
#include "add-interactive.h"
28+
#include "hook.h"
29+
#include "abspath.h"
2830

2931
static const char * const builtin_add_usage[] = {
3032
N_("git add [<options>] [--] <pathspec>..."),
@@ -36,6 +38,7 @@ static int take_worktree_changes;
3638
static int add_renormalize;
3739
static int pathspec_file_nul;
3840
static int include_sparse;
41+
static int no_verify;
3942
static const char *pathspec_from_file;
4043

4144
static int chmod_pathspec(struct repository *repo,
@@ -271,6 +274,7 @@ static struct option builtin_add_options[] = {
271274
OPT_BOOL( 0 , "refresh", &refresh_only, N_("don't add, only refresh the index")),
272275
OPT_BOOL( 0 , "ignore-errors", &ignore_add_errors, N_("just skip files which cannot be added because of errors")),
273276
OPT_BOOL( 0 , "ignore-missing", &ignore_missing, N_("check if - even missing - files are ignored in dry run")),
277+
OPT_BOOL( 0 , "no-verify", &no_verify, N_("bypass pre-add hook")),
274278
OPT_BOOL(0, "sparse", &include_sparse, N_("allow updating entries outside of the sparse-checkout cone")),
275279
OPT_STRING(0, "chmod", &chmod_arg, "(+|-)x",
276280
N_("override the executable bit of the listed files")),
@@ -391,6 +395,8 @@ int cmd_add(int argc,
391395
char *ps_matched = NULL;
392396
struct lock_file lock_file = LOCK_INIT;
393397
struct odb_transaction *transaction;
398+
int run_pre_add = 0;
399+
char *orig_index_path = NULL;
394400

395401
repo_config(repo, add_config, NULL);
396402

@@ -576,6 +582,11 @@ int cmd_add(int argc,
576582
string_list_clear(&only_match_skip_worktree, 0);
577583
}
578584

585+
if (!show_only && !no_verify && find_hook(repo, "pre-add")) {
586+
run_pre_add = 1;
587+
orig_index_path = absolute_pathdup(repo_get_index_file(repo));
588+
}
589+
579590
transaction = odb_transaction_begin(repo->objects);
580591

581592
ps_matched = xcalloc(pathspec.nr, 1);
@@ -587,8 +598,10 @@ int cmd_add(int argc,
587598
include_sparse, flags);
588599

589600
if (take_worktree_changes && !add_renormalize && !ignore_add_errors &&
590-
report_path_error(ps_matched, &pathspec))
601+
report_path_error(ps_matched, &pathspec)) {
602+
free(orig_index_path);
591603
exit(128);
604+
}
592605

593606
if (add_new_files)
594607
exit_status |= add_files(repo, &dir, flags);
@@ -598,9 +611,35 @@ int cmd_add(int argc,
598611
odb_transaction_commit(transaction);
599612

600613
finish:
601-
if (write_locked_index(repo->index, &lock_file,
602-
COMMIT_LOCK | SKIP_IF_UNCHANGED))
603-
die(_("unable to write new index file"));
614+
if (run_pre_add && repo->index->cache_changed) {
615+
struct run_hooks_opt opt = RUN_HOOKS_OPT_INIT;
616+
617+
if (write_locked_index(repo->index, &lock_file,
618+
SKIP_INDEX_CHANGE_HOOK))
619+
die(_("unable to write proposed index"));
620+
621+
strvec_push(&opt.args, orig_index_path);
622+
strvec_push(&opt.args, get_lock_file_path(&lock_file));
623+
if (run_hooks_opt(repo, "pre-add", &opt)) {
624+
rollback_lock_file(&lock_file); /* hook rejected */
625+
exit_status = 1;
626+
} else if (commit_lock_file(&lock_file)) {
627+
die(_("unable to write new index file"));
628+
} else {
629+
run_hooks_l(repo, "post-index-change",
630+
repo->index->updated_workdir ? "1" : "0",
631+
repo->index->updated_skipworktree ? "1" : "0",
632+
NULL);
633+
}
634+
repo->index->updated_workdir = 0;
635+
repo->index->updated_skipworktree = 0;
636+
} else {
637+
if (write_locked_index(repo->index, &lock_file,
638+
COMMIT_LOCK | SKIP_IF_UNCHANGED))
639+
die(_("unable to write new index file"));
640+
}
641+
642+
free(orig_index_path);
604643

605644
free(ps_matched);
606645
dir_clear(&dir);

read-cache-ll.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ int is_index_unborn(struct index_state *);
284284
/* For use with `write_locked_index()`. */
285285
#define COMMIT_LOCK (1 << 0)
286286
#define SKIP_IF_UNCHANGED (1 << 1)
287+
#define SKIP_INDEX_CHANGE_HOOK (1 << 2)
287288

288289
/*
289290
* Write the index while holding an already-taken lock. Close the lock,

read-cache.c

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3161,12 +3161,13 @@ static int do_write_locked_index(struct index_state *istate,
31613161
else
31623162
ret = close_lock_file_gently(lock);
31633163

3164-
run_hooks_l(the_repository, "post-index-change",
3165-
istate->updated_workdir ? "1" : "0",
3166-
istate->updated_skipworktree ? "1" : "0", NULL);
3167-
istate->updated_workdir = 0;
3168-
istate->updated_skipworktree = 0;
3169-
3164+
if (!(flags & SKIP_INDEX_CHANGE_HOOK)) {
3165+
run_hooks_l(the_repository, "post-index-change",
3166+
istate->updated_workdir ? "1" : "0",
3167+
istate->updated_skipworktree ? "1" : "0", NULL);
3168+
istate->updated_workdir = 0;
3169+
istate->updated_skipworktree = 0;
3170+
}
31703171
return ret;
31713172
}
31723173

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ integration_tests = [
415415
't3703-add-magic-pathspec.sh',
416416
't3704-add-pathspec-file.sh',
417417
't3705-add-sparse-checkout.sh',
418+
't3706-pre-add-hook.sh',
418419
't3800-mktag.sh',
419420
't3900-i18n-commit.sh',
420421
't3901-i18n-patch.sh',

0 commit comments

Comments
 (0)